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);