diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index fa5392d9e..8fc193b32 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -1,6 +1,7 @@ -import type { Express } from 'express'; +import { randomUUID } from 'node:crypto'; import { rm } from 'node:fs/promises'; import path from 'node:path'; +import type { Express, Response } from 'express'; import { defaultScenarioPluginIdForProjectMetadata, type PluginManifest, @@ -1208,6 +1209,104 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const { listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, ensureProject } = ctx.projectFiles; const { buildDocumentPreview } = ctx.documents; const { validateArtifactManifestInput } = ctx.artifacts; + const projectPreviewIframeSandbox = 'allow-scripts allow-forms'; + const projectPreviewCsp = [ + `sandbox ${projectPreviewIframeSandbox}`, + "default-src 'self' data: blob:", + "img-src 'self' data: blob:", + "media-src 'self' data: blob:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "connect-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "object-src 'none'", + ].join('; '); + const previewScopeRe = /^[A-Za-z0-9_-]{8,128}$/u; + + function setProjectPreviewHeaders(res: Response) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', projectPreviewCsp); + } + + async function sendProjectFile( + req: any, + res: Response, + projectId: string, + relPath: string, + metadata?: unknown, + beforeSend?: (mime: string) => void, + transformFile?: (file: { mime: string; buffer: Buffer }) => Buffer | string, + ) { + const meta = await resolveProjectFilePath( + PROJECTS_DIR, + projectId, + relPath, + metadata, + ); + beforeSend?.(meta.mime); + + if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) { + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('Content-Type', meta.mime); + + if (meta.size === 0) { + res.setHeader('Content-Length', '0'); + return res.status(200).end(); + } + + const range = parseByteRange(req.headers.range, meta.size); + + if (range === 'unsatisfiable') { + res.setHeader('Content-Range', `bytes */${meta.size}`); + return res.status(416).end(); + } + + let start; + let end; + let statusCode; + if (range) { + ({ start, end } = range); + statusCode = 206; + res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`); + res.setHeader('Content-Length', String(end - start + 1)); + } else { + start = 0; + end = meta.size - 1; + statusCode = 200; + res.setHeader('Content-Length', String(meta.size)); + } + + res.status(statusCode); + const stream = fs.createReadStream(meta.filePath, { start, end }); + stream.on('error', (streamErr: any) => { + if (!res.headersSent) { + sendApiError(res, 500, 'STREAM_ERROR', String(streamErr)); + } else { + res.destroy(streamErr); + } + }); + stream.pipe(res); + return; + } + + const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, metadata); + res.type(file.mime).send(transformFile ? transformFile(file) : file.buffer); + } + + function previewFilePathForProject(project: any, queryFile: unknown): string { + if (typeof queryFile === 'string' && queryFile.trim().length > 0) { + return queryFile; + } + const entryFile = project?.metadata?.entryFile; + return typeof entryFile === 'string' && entryFile.length > 0 ? entryFile : 'index.html'; + } + + function encodeProjectPathForUrl(filePath: string): string { + return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/'); + } // Project files. Each project owns a flat folder under .od/projects// // containing every file the user has uploaded, pasted, sketched, or that @@ -1266,6 +1365,79 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); + app.get('/api/projects/:id/preview-url', async (req, res) => { + try { + const project = getProject(db, req.params.id); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + const requestedPath = previewFilePathForProject(project, req.query.file); + const meta = await resolveProjectFilePath( + PROJECTS_DIR, + project.id, + requestedPath, + project.metadata, + ); + const scope = randomUUID(); + /** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */ + const body = { + url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`, + file: meta.name, + csp: projectPreviewCsp, + iframeSandbox: projectPreviewIframeSandbox, + opaqueOrigin: true, + }; + res.setHeader('Cache-Control', 'no-store'); + res.json(body); + } catch (err: any) { + const status = err && err.code === 'ENOENT' ? 404 : 400; + sendApiError( + res, + status, + status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', + String(err), + ); + } + }); + + app.get(/^\/api\/projects\/([^/]+)\/preview\/([^/]+)\/(.+)$/u, async (req, res) => { + try { + const params = req.params as unknown as { 0?: string; 1?: string; 2?: string }; + const projectId = String(params[0] ?? ''); + const scope = String(params[1] ?? ''); + const relPath = String(params[2] ?? ''); + if (!previewScopeRe.test(scope)) { + sendApiError(res, 400, 'BAD_REQUEST', 'invalid preview scope'); + return; + } + const project = getProject(db, projectId); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + if (req.headers.origin === 'null') { + res.header('Access-Control-Allow-Origin', '*'); + } + await sendProjectFile( + req, + res, + project.id, + relPath, + project.metadata, + () => setProjectPreviewHeaders(res), + ); + } catch (err: any) { + const status = err && err.code === 'ENOENT' ? 404 : 400; + sendApiError( + res, + status, + status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', + String(err), + ); + } + }); + // Preflight for the raw file route. Current artifact fetches are simple GETs // (no preflight needed), but an explicit handler future-proofs the route if @@ -1293,66 +1465,23 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile res.header('Access-Control-Allow-Origin', '*'); } - const meta = await resolveProjectFilePath( - PROJECTS_DIR, + await sendProjectFile( + req, + res, projectId, relPath, project?.metadata, - ); - - if (meta.mime.startsWith('video/') || meta.mime.startsWith('audio/')) { - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', meta.mime); - - if (meta.size === 0) { - res.setHeader('Content-Length', '0'); - return res.status(200).end(); - } - - const range = parseByteRange(req.headers.range, meta.size); - - if (range === 'unsatisfiable') { - res.setHeader('Content-Range', `bytes */${meta.size}`); - return res.status(416).end(); - } - - let start; - let end; - let statusCode; - if (range) { - ({ start, end } = range); - statusCode = 206; - res.setHeader('Content-Range', `bytes ${start}-${end}/${meta.size}`); - res.setHeader('Content-Length', String(end - start + 1)); - } else { - start = 0; - end = meta.size - 1; - statusCode = 200; - res.setHeader('Content-Length', String(meta.size)); - } - - res.status(statusCode); - const stream = fs.createReadStream(meta.filePath, { start, end }); - stream.on('error', (streamErr: any) => { - if (!res.headersSent) { - sendApiError(res, 500, 'STREAM_ERROR', String(streamErr)); - } else { - res.destroy(streamErr); + undefined, + (file) => { + if ( + wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) && + /^text\/html(?:;|$)/i.test(file.mime) + ) { + return injectUrlPreviewScrollBridge(file.buffer.toString('utf8')); } - }); - stream.pipe(res); - return; - } - - const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata); - if ( - wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) && - /^text\/html(?:;|$)/i.test(file.mime) - ) { - res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8'))); - return; - } - res.type(file.mime).send(file.buffer); + return file.buffer; + }, + ); } catch (err: any) { const status = err && err.code === 'ENOENT' ? 404 : 400; sendApiError( diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index 1b8e72627..8ce69f39e 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -626,7 +626,8 @@ export async function resolveProjectFilePath(projectsRoot, projectId, name, meta const dir = resolveProjectDir(projectsRoot, projectId, metadata); const file = await resolveSafeReal(dir, name); const st = await stat(file); - const rel = toProjectPath(path.relative(dir, file)); + const rootReal = await realpath(dir).catch(() => dir); + const rel = toProjectPath(path.relative(rootReal, file)); return { filePath: file, name: rel, diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 6ea861454..797caaf85 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -4174,7 +4174,7 @@ export async function startServer({ // Routes that serve content to sandboxed iframes (Origin: null) for // read-only purposes. All other /api routes reject Origin: null. const _NULL_ORIGIN_SAFE_GET_RE = - /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; + /^\/projects\/[^/]+\/(?:raw|preview)\/|^\/codex-pets\/[^/]+\/spritesheet$/; // Reject cross-origin requests to API endpoints. // Health/version remain open for monitoring probes. diff --git a/apps/daemon/tests/project-preview-containment.test.ts b/apps/daemon/tests/project-preview-containment.test.ts new file mode 100644 index 000000000..1bdc59927 --- /dev/null +++ b/apps/daemon/tests/project-preview-containment.test.ts @@ -0,0 +1,122 @@ +import type http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +describe('project preview containment routes', () => { + let server: http.Server; + let baseUrl: string; + const projectsToClean: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(async () => { + for (const id of projectsToClean.splice(0)) { + await fetch(`${baseUrl}/api/projects/${id}`, { method: 'DELETE' }).catch(() => {}); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + async function createProject(metadata: Record = {}): Promise { + const id = `preview-containment-${randomUUID()}`; + const response = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id, + name: 'Preview containment project', + metadata, + }), + }); + expect(response.ok).toBe(true); + projectsToClean.push(id); + return id; + } + + async function writeProjectFile(projectId: string, name: string, content: string): Promise { + const response = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name, content }), + }); + expect(response.ok).toBe(true); + } + + it('returns a scoped preview URL with sandbox guidance and serves it with an opaque-origin CSP', async () => { + const projectId = await createProject({ entryFile: 'pages/index.html' }); + await writeProjectFile( + projectId, + 'pages/index.html', + 'Preview', + ); + await writeProjectFile(projectId, 'styles/app.css', 'body { color: black; }'); + + const urlResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('pages/index.html')}`, + ); + expect(urlResponse.ok).toBe(true); + expect(urlResponse.headers.get('cache-control')).toBe('no-store'); + const body = await urlResponse.json() as { + url: string; + file: string; + csp: string; + iframeSandbox: string; + opaqueOrigin: true; + }; + + expect(body.file).toBe('pages/index.html'); + expect(body.url).toContain(`/api/projects/${projectId}/preview/`); + expect(body.url).toMatch(/\/preview\/[A-Za-z0-9_-]{8,128}\/pages\/index\.html$/u); + expect(body.iframeSandbox).toBe('allow-scripts allow-forms'); + expect(body.iframeSandbox).not.toContain('allow-same-origin'); + expect(body.csp).toContain('sandbox allow-scripts allow-forms'); + expect(body.csp).toContain("connect-src 'none'"); + expect(body.csp).not.toContain('allow-same-origin'); + expect(body.opaqueOrigin).toBe(true); + + const previewResponse = await fetch(`${baseUrl}${body.url}`, { + headers: { Origin: 'null' }, + }); + expect(previewResponse.status).toBe(200); + expect(previewResponse.headers.get('access-control-allow-origin')).toBe('*'); + expect(previewResponse.headers.get('cache-control')).toBe('no-store'); + expect(previewResponse.headers.get('x-content-type-options')).toBe('nosniff'); + const csp = previewResponse.headers.get('content-security-policy') ?? ''; + expect(csp).toContain('sandbox allow-scripts allow-forms'); + expect(csp).toContain("connect-src 'none'"); + expect(csp).not.toContain('allow-same-origin'); + expect(await previewResponse.text()).toContain('Preview'); + + const scope = body.url.match(/\/preview\/([^/]+)\//u)?.[1]; + expect(scope).toBeTruthy(); + const assetResponse = await fetch( + `${baseUrl}/api/projects/${projectId}/preview/${scope}/styles/app.css`, + { headers: { Origin: 'null' } }, + ); + expect(assetResponse.status).toBe(200); + expect(assetResponse.headers.get('access-control-allow-origin')).toBe('*'); + expect(assetResponse.headers.get('content-type')).toContain('text/css'); + expect(await assetResponse.text()).toContain('color: black'); + }); + + it('rejects invalid preview scopes and escaping preview-url paths', async () => { + const projectId = await createProject(); + await writeProjectFile(projectId, 'index.html', ''); + + const invalidScope = await fetch(`${baseUrl}/api/projects/${projectId}/preview/bad/index.html`); + expect(invalidScope.status).toBe(400); + + const escapingPath = await fetch( + `${baseUrl}/api/projects/${projectId}/preview-url?file=${encodeURIComponent('../index.html')}`, + ); + expect(escapingPath.status).toBe(400); + }); +}); diff --git a/packages/contracts/src/api/files.ts b/packages/contracts/src/api/files.ts index 450b56261..cab652da5 100644 --- a/packages/contracts/src/api/files.ts +++ b/packages/contracts/src/api/files.ts @@ -81,6 +81,14 @@ export interface ProjectExportManifestResponse { artifacts: ProjectExportManifestArtifact[]; } +export interface ProjectPreviewUrlResponse { + url: string; + file: string; + csp: string; + iframeSandbox: string; + opaqueOrigin: true; +} + export interface ProjectFileResponse { file: ProjectFile; }