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