84 lines
3.4 KiB
TypeScript
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;
|
|
}
|