maestro/src/bridge/local-api-helpers.ts
oss-sync d061ad08d8
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

84 lines
3.4 KiB
TypeScript

import { type Request, type Response } from 'express';
import { join, resolve, sep } from 'path';
export function getLocalWorkspacePath(worktreeDir: string | undefined, taskId: number): string {
const base = worktreeDir ?? '/tmp/maestro/workspaces';
return join(base, 'local', String(taskId));
}
export function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
/**
* Harden a response that serves user/agent-authored workspace bytes.
*
* Workspace files can hold attacker-influenced HTML/SVG (an agent driven by a
* poisoned web page, or another user's shared task). Without these headers the
* browser renders such a file inline on the app origin and any embedded
* `<script>` runs with the viewer's session — stored XSS, and via the
* unauthenticated share endpoint it crosses to other users. `Content-Security-Policy:
* sandbox` forces an opaque origin and disables script execution while still
* letting the in-app preview render the markup; `nosniff` stops the browser from
* sniffing a text/* file back into executable HTML.
*/
export function setUntrustedFileResponseHeaders(res: Response): void {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', 'sandbox');
}
/** True when the error came from ensurePathWithin's traversal guard. */
export function isPathEscapeError(err: unknown): boolean {
return err instanceof Error && err.message === 'Path escapes workspace';
}
export function serializeLocalFileEntry(relativePath: string, name: string, isDirectory: boolean, size: number, mtime: Date) {
return {
name,
path: relativePath ? `${relativePath}/${name}` : name,
kind: isDirectory ? 'directory' : 'file',
size,
modifiedAt: mtime.toISOString(),
};
}
export function getOwnerFilter(req: Request): { ownerId?: string } {
if (!req.user) return {};
if (req.user.role === 'admin') return {};
return { ownerId: req.user.id };
}
export function checkTaskOwnership(req: Request, res: Response, task: { ownerId?: string | null } | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
if (req.user && req.user.role !== 'admin' && task.ownerId !== req.user?.id) {
res.status(404).json({ error: 'Task not found' });
return false;
}
return true;
}
type TaskLike = {
ownerId?: string | null;
visibility?: 'private' | 'org' | 'public' | null;
visibilityScopeOrgId?: string | null;
};
// Read-side permission check honoring the full visibility model.
// Writes should continue to use checkTaskOwnership (owner-or-admin only).
export function canViewTask(req: Request, res: Response, task: TaskLike | null): boolean {
if (!task) { res.status(404).json({ error: 'Task not found' }); return false; }
const user = req.user as Express.User | undefined;
if (!user) return true;
if (user.role === 'admin') return true;
if (task.ownerId && task.ownerId === user.id) return true;
if (task.visibility === 'public') return true;
if (task.visibility === 'org' && task.visibilityScopeOrgId && user.orgIds?.includes(task.visibilityScopeOrgId)) return true;
res.status(404).json({ error: 'Task not found' });
return false;
}