fix(scripts): sandbox HTML siblings, link-name MIME, portable fixtures

Review follow-ups: serve text/html sibling assets under the artifact sandbox CSP (direct navigation no longer bypasses it); resolve MIME from the request name first with target extension as fallback; make security fixtures cross-platform (no angle brackets in filenames, skip when symlink creation is denied). Residual check-then-read TOCTOU documented as accepted for the loopback-local threat model. Tests 33/33 GREEN, ESLint clean.
This commit is contained in:
WRG
2026-09-26 02:39:42 +10:00
parent 742395bca7
commit d237e1972f
2 changed files with 47 additions and 7 deletions
+15 -2
View File
@@ -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);
}
+32 -5
View File
@@ -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<img src=x onerror=alert(1)>.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('<img src=x'), 'raw filename must not appear in the 404 page');
assert.ok(res.body.includes('&lt;img'), 'filename must be HTML-escaped in the 404 page');
assert.ok(!res.body.includes(`evil'b&xss`), 'raw filename must not appear in the 404 page');
assert.ok(res.body.includes('evil&#39;b&amp;xss'), 'filename must be HTML-escaped in the 404 page');
})) passed++; else failed++;
if (await test('symlinked sibling assets escaping the artifact dir are blocked', async () => {
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'), '<!DOCTYPE html><html><body><p>hi</p></body></html>');
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);