mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 21:15:16 +02:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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('<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'b&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);
|
||||
|
||||
Reference in New Issue
Block a user