diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js index 961dd559c..87998b9dd 100644 --- a/scripts/lib/plan-canvas/server.js +++ b/scripts/lib/plan-canvas/server.js @@ -541,14 +541,27 @@ function createPlanCanvasServer({ } catch { return sendJson(res, 404, { error: 'asset not found' }); } + // Residual TOCTOU note: the confinement check and the read below are + // separate operations, so a local actor racing a symlink swap between + // them could redirect the read. Accepted for a loopback-local dev tool: + // anyone able to win that race already has arbitrary local file write, + // and served HTML is sandboxed (see below) while other types are inert. let data; try { data = fs.readFileSync(realTarget); } catch { return sendJson(res, 404, { error: 'asset not found' }); } - const type = CONTENT_TYPES[path.extname(realTarget).toLowerCase()] || 'application/octet-stream'; - res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); + // MIME comes from the link/request name first so a symlinked asset keeps + // the type the page asked for; the target extension is the fallback. + const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] + || CONTENT_TYPES[path.extname(realTarget).toLowerCase()] + || 'application/octet-stream'; + const headers = { 'content-type': type, 'cache-control': 'no-store' }; + if (type.startsWith('text/html')) { + headers['content-security-policy'] = ARTIFACT_CSP; + } + res.writeHead(200, headers); return res.end(data); } diff --git a/tests/scripts/plan-canvas.test.js b/tests/scripts/plan-canvas.test.js index 152d9c35b..2ab43ec1f 100644 --- a/tests/scripts/plan-canvas.test.js +++ b/tests/scripts/plan-canvas.test.js @@ -224,26 +224,53 @@ async function main() { })) passed++; else failed++; if (await test('missing-artifact 404 escapes the file path', async () => { - const evilFile = path.join(tmp, 'evil.plan.md'); + // Quotes and ampersands are escapable on every platform (Windows + // rejects < > in filenames, so angle brackets stay out of fixtures). + const evilFile = path.join(tmp, `evil'b&xss.plan.md`); fs.writeFileSync(evilFile, '# Evil\n'); const opened = jsonBody(await request(port, 'POST', '/api/sessions', { body: { file: evilFile } })); fs.rmSync(evilFile); const res = await request(port, 'GET', `/artifact/${opened.key}/`); assert.strictEqual(res.statusCode, 404); - assert.ok(!res.body.includes(' { - fs.symlinkSync(path.join(outsideDir, 'secret.txt'), path.join(tmp, 'evil-link.txt')); + try { + fs.symlinkSync(path.join(outsideDir, 'secret.txt'), path.join(tmp, 'evil-link.txt')); + fs.symlinkSync(path.join(tmp, 'style.css'), path.join(tmp, 'ok-link.css')); + } catch { + console.log(' SKIP: symlink creation unavailable on this platform'); + return; + } const blocked = await request(port, 'GET', `/artifact/${key}/evil-link.txt`); assert.strictEqual(blocked.statusCode, 403); - fs.symlinkSync(path.join(tmp, 'style.css'), path.join(tmp, 'ok-link.css')); const allowed = await request(port, 'GET', `/artifact/${key}/ok-link.css`); assert.strictEqual(allowed.statusCode, 200); assert.ok(allowed.body.includes('color: red')); })) passed++; else failed++; + if (await test('served HTML siblings carry the sandbox CSP', async () => { + fs.writeFileSync(path.join(tmp, 'note.html'), '

hi

'); + const res = await request(port, 'GET', `/artifact/${key}/note.html`); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.headers['content-security-policy'], 'sandbox allow-scripts allow-forms allow-popups'); + })) passed++; else failed++; + + if (await test('symlinked assets take their MIME from the link name', async () => { + try { + fs.writeFileSync(path.join(tmp, 'realfile'), 'body { color: blue }'); + fs.symlinkSync(path.join(tmp, 'realfile'), path.join(tmp, 'theme.css')); + } catch { + console.log(' SKIP: symlink creation unavailable on this platform'); + return; + } + const res = await request(port, 'GET', `/artifact/${key}/theme.css`); + assert.strictEqual(res.statusCode, 200); + assert.ok(String(res.headers['content-type']).startsWith('text/css')); + })) passed++; else failed++; + if (await test('static chrome assets are served', async () => { for (const asset of ['/canvas.css', '/client.js', '/sdk.js']) { const res = await request(port, 'GET', asset);