From c4cde1eed9a33c50f758fa8e06e8eebbcaddd6fe Mon Sep 17 00:00:00 2001 From: Drew Ritter Date: Wed, 10 Jun 2026 18:25:03 -0700 Subject: [PATCH] Harden root screen containment --- skills/brainstorming/scripts/server.cjs | 2 + tests/brainstorm-server/server.test.js | 89 ++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index 90eff801..d2535f87 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -186,8 +186,10 @@ function getNewestScreen() { .filter(f => !f.startsWith('.') && f.endsWith('.html')) .map(f => { const fp = path.join(CONTENT_DIR, f); + if (!isRegularFileInsideContentDir(fp)) return null; return { path: fp, mtime: fs.statSync(fp).mtime.getTime() }; }) + .filter(Boolean) .sort((a, b) => b.mtime - a.mtime); return files.length > 0 ? files[0].path : null; } diff --git a/tests/brainstorm-server/server.test.js b/tests/brainstorm-server/server.test.js index e99c0de5..6793ef82 100644 --- a/tests/brainstorm-server/server.test.js +++ b/tests/brainstorm-server/server.test.js @@ -73,6 +73,43 @@ async function waitForServer(server) { }); } +class SkipTest extends Error { + constructor(message) { + super(message); + this.skip = true; + } +} + +function skip(message) { + throw new SkipTest(message); +} + +function serverStartedMessage(out) { + const line = out.trim().split('\n').find(l => l.includes('server-started')); + assert(line, 'server-started JSON should be present'); + return JSON.parse(line); +} + +function assertStartedOnExpectedPort(out) { + const msg = serverStartedMessage(out); + assert.strictEqual( + msg.port, + TEST_PORT, + `server.test.js expected fixed port ${TEST_PORT}, got ${msg.port}; fixed-port tests must not run through fallback` + ); + return msg; +} + +function ensureSymlinkWorks(target, link) { + try { + fs.symlinkSync(target, link); + fs.unlinkSync(link); + } catch (e) { + try { fs.unlinkSync(link); } catch (ignore) {} + skip(`symlink creation unavailable on this host: ${e.message}`); + } +} + async function runTests() { cleanup(); @@ -81,14 +118,22 @@ async function runTests() { server.stdout.on('data', (data) => { stdoutAccum += data.toString(); }); const { stdout: initialStdout } = await waitForServer(server); + assertStartedOnExpectedPort(initialStdout); let passed = 0; let failed = 0; + let skipped = 0; function test(name, fn) { return fn().then(() => { console.log(` PASS: ${name}`); passed++; }).catch(e => { + if (e.skip) { + console.log(` SKIP: ${name}`); + console.log(` ${e.message}`); + skipped++; + return; + } console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; @@ -100,7 +145,7 @@ async function runTests() { console.log('\n--- Server Startup ---'); await test('outputs server-started JSON on startup', () => { - const msg = JSON.parse(initialStdout.trim()); + const msg = serverStartedMessage(initialStdout); assert.strictEqual(msg.type, 'server-started'); assert.strictEqual(msg.port, TEST_PORT); assert(msg.url, 'Should include URL'); @@ -214,6 +259,7 @@ async function runTests() { const target = path.join(STATE_DIR, 'server-info'); const link = path.join(CONTENT_DIR, 'linked-server-info.txt'); try { fs.unlinkSync(link); } catch (e) {} + ensureSymlinkWorks(target, link); fs.symlinkSync(target, link); const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`); @@ -232,6 +278,45 @@ async function runTests() { assert(!res.body.includes('server-started'), 'response must not include server-info body'); }); + await test('does not serve symlinks that escape content dir via root screen selection', async () => { + const target = path.join(STATE_DIR, 'server-info'); + const link = path.join(CONTENT_DIR, 'root-linked-server-info.html'); + try { fs.unlinkSync(link); } catch (e) {} + ensureSymlinkWorks(target, link); + fs.symlinkSync(target, link); + const future = new Date(Date.now() + 2000); + fs.utimesSync(target, future, future); + await sleep(300); + + const res = await fetch(`http://localhost:${TEST_PORT}/`); + assert.strictEqual(res.status, 200); + assert(!res.body.includes('"type":"server-started"'), 'root screen must not serve state/server-info through a symlink'); + assert(!res.body.includes('"state_dir"'), 'root screen must not include server-info body'); + }); + + await test('does not serve hard links that escape content dir via root screen selection', async () => { + const target = path.join(STATE_DIR, 'server-info'); + const link = path.join(CONTENT_DIR, 'root-hard-linked-server-info.html'); + try { fs.unlinkSync(link); } catch (e) {} + try { + fs.linkSync(target, link); + } catch (e) { + skip(`hardlink creation unavailable on this host: ${e.message}`); + } + const linkStat = fs.lstatSync(link); + if (linkStat.nlink <= 1) { + skip(`hardlink nlink did not expose multiple links: ${linkStat.nlink}`); + } + const future = new Date(Date.now() + 3000); + fs.utimesSync(target, future, future); + await sleep(300); + + const res = await fetch(`http://localhost:${TEST_PORT}/`); + assert.strictEqual(res.status, 200); + assert(!res.body.includes('"type":"server-started"'), 'root screen must not serve state/server-info through a hardlink'); + assert(!res.body.includes('"state_dir"'), 'root screen must not include server-info body'); + }); + await test('returns 404 for non-root paths', async () => { const res = await fetch(`http://localhost:${TEST_PORT}/other`); assert.strictEqual(res.status, 404); @@ -480,7 +565,7 @@ async function runTests() { }); // ========== Summary ========== - console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`); + console.log(`\n--- Results: ${passed} passed, ${failed} failed, ${skipped} skipped ---`); if (failed > 0) process.exit(1); } finally {