Prechádzať zdrojové kódy

Harden brainstorm companion auth regressions

Drew Ritter 3 mesiacov pred
rodič
commit
a2e67bbd9b

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 .worktrees/
 .worktrees/
 .private-journal/
 .private-journal/
 .claude/
 .claude/
+.superpowers/
 .DS_Store
 .DS_Store
 node_modules/
 node_modules/
 inspo
 inspo

+ 25 - 4
skills/brainstorming/scripts/helper.js

@@ -14,7 +14,6 @@
   // Everything below is browser-only; bail out when loaded in Node (tests).
   // Everything below is browser-only; bail out when loaded in Node (tests).
   if (typeof window === 'undefined') return;
   if (typeof window === 'undefined') return;
 
 
-  const WS_URL = 'ws://' + window.location.host;
   let ws = null;
   let ws = null;
   let eventQueue = [];
   let eventQueue = [];
   let reconnectDelay = MIN_RECONNECT_MS;
   let reconnectDelay = MIN_RECONNECT_MS;
@@ -23,6 +22,27 @@
   let everConnected = false;
   let everConnected = false;
   let tombstoneShown = false;
   let tombstoneShown = false;
 
 
+  function sessionKey() {
+    try {
+      return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
+    } catch (e) {}
+    return null;
+  }
+
+  function websocketUrl() {
+    const key = sessionKey();
+    return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
+  }
+
+  function reloadAfterRecovery() {
+    const key = sessionKey();
+    if (key) {
+      window.location.replace('/?key=' + encodeURIComponent(key));
+    } else {
+      window.location.reload();
+    }
+  }
+
   // Reflect connection state in the frame's status pill (absent on full-doc screens).
   // Reflect connection state in the frame's status pill (absent on full-doc screens).
   function setStatus(state) {
   function setStatus(state) {
     const el = document.querySelector('.status');
     const el = document.querySelector('.status');
@@ -57,7 +77,7 @@
   function connect() {
   function connect() {
     if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
     if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
     setStatus(everConnected ? 'reconnecting' : 'connecting');
     setStatus(everConnected ? 'reconnecting' : 'connecting');
-    ws = new WebSocket(WS_URL);
+    ws = new WebSocket(websocketUrl());
 
 
     ws.onopen = () => {
     ws.onopen = () => {
       const recovered = tombstoneShown;
       const recovered = tombstoneShown;
@@ -69,8 +89,9 @@
       eventQueue.forEach(e => ws.send(JSON.stringify(e)));
       eventQueue.forEach(e => ws.send(JSON.stringify(e)));
       eventQueue = [];
       eventQueue = [];
       // Recovered from a tombstoned outage (e.g. the server restarted on the same
       // Recovered from a tombstoned outage (e.g. the server restarted on the same
-      // port) — reload to pick up the restarted server's current screen.
-      if (recovered) window.location.reload();
+      // port) — reload through the keyed bootstrap when possible so the cookie is
+      // refreshed before the visible URL returns to bare /.
+      if (recovered) reloadAfterRecovery();
     };
     };
 
 
     ws.onmessage = (msg) => {
     ws.onmessage = (msg) => {

+ 74 - 14
skills/brainstorming/scripts/server.cjs

@@ -152,6 +152,20 @@ h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1e
 <p>This page needs the full URL your coding agent gave you, including the
 <p>This page needs the full URL your coding agent gave you, including the
 <code>?key=&hellip;</code> part. Copy the complete URL and open it again.</p></body></html>`;
 <code>?key=&hellip;</code> part. Copy the complete URL and open it again.</p></body></html>`;
 
 
+function bootstrapPage(key) {
+  const jsonKey = JSON.stringify(String(key));
+  return `<!DOCTYPE html>
+<html>
+<head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
+<body>
+<script>
+try { sessionStorage.setItem('brainstorm-session-key', ${jsonKey}); } catch (e) {}
+location.replace('/');
+</script>
+</body>
+</html>`;
+}
+
 const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
 const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
 const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
 const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
 const helperInjection = '<script>\n' + helperScript + '\n</script>';
 const helperInjection = '<script>\n' + helperScript + '\n</script>';
@@ -178,6 +192,21 @@ function getNewestScreen() {
   return files.length > 0 ? files[0].path : null;
   return files.length > 0 ? files[0].path : null;
 }
 }
 
 
+function isRegularFileInsideContentDir(filePath) {
+  let stat, realContentDir, realFilePath;
+  try {
+    stat = fs.lstatSync(filePath);
+    if (stat.isSymbolicLink()) return false;
+    if (!stat.isFile()) return false;
+    if (stat.nlink !== 1) return false;
+    realContentDir = fs.realpathSync(CONTENT_DIR);
+    realFilePath = fs.realpathSync(filePath);
+  } catch (e) {
+    return false;
+  }
+  return realFilePath.startsWith(realContentDir + path.sep);
+}
+
 // ========== Authentication ==========
 // ========== Authentication ==========
 
 
 function timingSafeEqualStr(a, b) {
 function timingSafeEqualStr(a, b) {
@@ -203,8 +232,11 @@ function parseCookies(header) {
 function isAuthorized(req) {
 function isAuthorized(req) {
   const q = req.url.indexOf('?');
   const q = req.url.indexOf('?');
   if (q >= 0) {
   if (q >= 0) {
-    const key = new URLSearchParams(req.url.slice(q + 1)).get('key');
-    if (key && timingSafeEqualStr(key, TOKEN)) return true;
+    const params = new URLSearchParams(req.url.slice(q + 1));
+    if (params.has('key')) {
+      const key = params.get('key');
+      return Boolean(key && timingSafeEqualStr(key, TOKEN));
+    }
   }
   }
   const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
   const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
   if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
   if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
@@ -216,25 +248,53 @@ function pathnameOf(url) {
   return q >= 0 ? url.slice(0, q) : url;
   return q >= 0 ? url.slice(0, q) : url;
 }
 }
 
 
+function queryKey(url) {
+  const q = url.indexOf('?');
+  if (q < 0) return null;
+  return new URLSearchParams(url.slice(q + 1)).get('key');
+}
+
+function securityHeaders(headers = {}) {
+  return {
+    'Referrer-Policy': 'no-referrer',
+    'Cache-Control': 'no-store',
+    'X-Frame-Options': 'DENY',
+    'Content-Security-Policy': "frame-ancestors 'none'",
+    'Cross-Origin-Resource-Policy': 'same-origin',
+    ...headers
+  };
+}
+
+function isAllowedWebSocketOrigin(req) {
+  const origin = req.headers.origin;
+  if (!origin) return true;
+  const host = req.headers.host;
+  if (!host) return false;
+  return origin === 'http://' + host;
+}
+
 // ========== HTTP Request Handler ==========
 // ========== HTTP Request Handler ==========
 
 
 function handleRequest(req, res) {
 function handleRequest(req, res) {
   if (!isAuthorized(req)) {
   if (!isAuthorized(req)) {
-    res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
+    res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
     res.end(FORBIDDEN_PAGE);
     res.end(FORBIDDEN_PAGE);
     return;
     return;
   }
   }
   touchActivity(); // only authorized requests count as activity
   touchActivity(); // only authorized requests count as activity
 
 
-  // Mirror the key into a cookie so same-origin subresources (/files/*) and the
-  // WebSocket handshake carry it automatically, whatever URL style the agent
-  // writes. SameSite=Strict: a cross-site page can neither read the key nor ride
-  // the cookie; HttpOnly: page scripts can't exfiltrate it.
+  // Mirror the key into a cookie so same-origin subresources (/files/*) can
+  // authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
+  // WebSocket Origin check below is what blocks cross-origin localhost injection.
   res.setHeader('Set-Cookie',
   res.setHeader('Set-Cookie',
     COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
     COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
 
 
   const pathname = pathnameOf(req.url);
   const pathname = pathnameOf(req.url);
-  if (req.method === 'GET' && pathname === '/') {
+  const keyFromQuery = queryKey(req.url);
+  if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
+    res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
+    res.end(bootstrapPage(keyFromQuery));
+  } else if (req.method === 'GET' && pathname === '/') {
     const screenFile = getNewestScreen();
     const screenFile = getNewestScreen();
     let html = screenFile
     let html = screenFile
       ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
       ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
@@ -246,24 +306,24 @@ function handleRequest(req, res) {
       html += helperInjection;
       html += helperInjection;
     }
     }
 
 
-    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+    res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
     res.end(html);
     res.end(html);
   } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
   } else if (req.method === 'GET' && pathname.startsWith('/files/')) {
     const fileName = path.basename(pathname.slice(7));
     const fileName = path.basename(pathname.slice(7));
     const filePath = path.join(CONTENT_DIR, fileName);
     const filePath = path.join(CONTENT_DIR, fileName);
     // Reject empty/dotfile names and anything that isn't a regular file —
     // Reject empty/dotfile names and anything that isn't a regular file —
     // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
     // `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
-    if (!fileName || fileName.startsWith('.') || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
-      res.writeHead(404);
+    if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
+      res.writeHead(404, securityHeaders());
       res.end('Not found');
       res.end('Not found');
       return;
       return;
     }
     }
     const ext = path.extname(filePath).toLowerCase();
     const ext = path.extname(filePath).toLowerCase();
     const contentType = MIME_TYPES[ext] || 'application/octet-stream';
     const contentType = MIME_TYPES[ext] || 'application/octet-stream';
-    res.writeHead(200, { 'Content-Type': contentType });
+    res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
     res.end(fs.readFileSync(filePath));
     res.end(fs.readFileSync(filePath));
   } else {
   } else {
-    res.writeHead(404);
+    res.writeHead(404, securityHeaders());
     res.end('Not found');
     res.end('Not found');
   }
   }
 }
 }
@@ -273,7 +333,7 @@ function handleRequest(req, res) {
 const clients = new Set();
 const clients = new Set();
 
 
 function handleUpgrade(req, socket) {
 function handleUpgrade(req, socket) {
-  if (!isAuthorized(req)) { socket.destroy(); return; }
+  if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
 
 
   const key = req.headers['sec-websocket-key'];
   const key = req.headers['sec-websocket-key'];
   if (!key) { socket.destroy(); return; }
   if (!key) { socket.destroy(); return; }

+ 2 - 2
skills/brainstorming/scripts/start-server.sh

@@ -125,7 +125,7 @@ if [[ -f "$PID_FILE" ]]; then
   rm -f "$PID_FILE"
   rm -f "$PID_FILE"
 fi
 fi
 
 
-cd "$SCRIPT_DIR"
+cd "$SCRIPT_DIR" || exit 1
 
 
 # Resolve the harness PID (grandparent of this script).
 # Resolve the harness PID (grandparent of this script).
 # $PPID is the ephemeral shell the harness spawned to run us — it dies
 # $PPID is the ephemeral shell the harness spawned to run us — it dies
@@ -163,7 +163,7 @@ disown "$SERVER_PID" 2>/dev/null
 echo "$SERVER_PID" > "$PID_FILE"
 echo "$SERVER_PID" > "$PID_FILE"
 
 
 # Wait for server-started message (check log file)
 # Wait for server-started message (check log file)
-for i in {1..50}; do
+for _ in {1..50}; do
   if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
   if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
     # Verify server is still alive after a short window (catches process reapers)
     # Verify server is still alive after a short window (catches process reapers)
     alive="true"
     alive="true"

+ 1 - 1
skills/brainstorming/scripts/stop-server.sh

@@ -54,7 +54,7 @@ if [[ -f "$PID_FILE" ]]; then
   kill "$pid" 2>/dev/null || true
   kill "$pid" 2>/dev/null || true
 
 
   # Wait for graceful shutdown (up to ~2s)
   # Wait for graceful shutdown (up to ~2s)
-  for i in {1..20}; do
+  for _ in {1..20}; do
     if ! kill -0 "$pid" 2>/dev/null; then
     if ! kill -0 "$pid" 2>/dev/null; then
       break
       break
     fi
     fi

+ 95 - 5
tests/brainstorm-server/auth.test.js

@@ -25,6 +25,13 @@ const TEST_DIR = '/tmp/brainstorm-auth-test';
 const CONTENT_DIR = path.join(TEST_DIR, 'content');
 const CONTENT_DIR = path.join(TEST_DIR, 'content');
 const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
 const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
 const COOKIE_NAME = `brainstorm-key-${TEST_PORT}`;
 const COOKIE_NAME = `brainstorm-key-${TEST_PORT}`;
+const EXPECTED_SECURITY_HEADERS = {
+  'referrer-policy': 'no-referrer',
+  'cache-control': 'no-store',
+  'x-frame-options': 'DENY',
+  'content-security-policy': "frame-ancestors 'none'",
+  'cross-origin-resource-policy': 'same-origin'
+};
 
 
 function cleanup() {
 function cleanup() {
   if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
   if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
@@ -49,9 +56,12 @@ function get(pathname, { key, cookie } = {}) {
 }
 }
 
 
 // Try to open a WebSocket; resolve 'opened' or 'rejected'.
 // Try to open a WebSocket; resolve 'opened' or 'rejected'.
-function wsConnect({ key, cookie } = {}) {
+function wsConnect({ key, cookie, origin } = {}) {
   const url = `ws://localhost:${TEST_PORT}/` + (key !== undefined ? `?key=${key}` : '');
   const url = `ws://localhost:${TEST_PORT}/` + (key !== undefined ? `?key=${key}` : '');
-  const opts = cookie ? { headers: { Cookie: cookie } } : {};
+  const headers = {};
+  if (cookie) headers['Cookie'] = cookie;
+  if (origin) headers['Origin'] = origin;
+  const opts = Object.keys(headers).length ? { headers } : {};
   const ws = new WebSocket(url, opts);
   const ws = new WebSocket(url, opts);
   return new Promise((resolve) => {
   return new Promise((resolve) => {
     let settled = false;
     let settled = false;
@@ -69,6 +79,21 @@ function startServer() {
   });
   });
 }
 }
 
 
+function assertSecurityHeaders(headers) {
+  for (const [name, value] of Object.entries(EXPECTED_SECURITY_HEADERS)) {
+    assert.strictEqual(headers[name], value, `${name} should be ${value}`);
+  }
+}
+
+function runBootstrapScript(html, sessionStorage) {
+  const match = html.match(/<script>\n([\s\S]*?)\n<\/script>/);
+  assert(match, 'bootstrap response should contain a script block');
+  const replacements = [];
+  const location = { replace(url) { replacements.push(url); } };
+  new Function('sessionStorage', 'location', match[1])(sessionStorage, location);
+  return replacements;
+}
+
 async function waitForServer(server) {
 async function waitForServer(server) {
   let stdout = '', stderr = '';
   let stdout = '', stderr = '';
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
@@ -120,15 +145,46 @@ async function runTests() {
       assert(/key/i.test(res.body), '403 body should mention the key');
       assert(/key/i.test(res.body), '403 body should mention the key');
     });
     });
 
 
+    await test('403 responses include leak-reduction and anti-framing headers', async () => {
+      const res = await get('/');
+      assert.strictEqual(res.status, 403);
+      assertSecurityHeaders(res.headers);
+    });
+
     await test('GET / with wrong key is rejected with 403', async () => {
     await test('GET / with wrong key is rejected with 403', async () => {
       const res = await get('/', { key: 'wrong-token' });
       const res = await get('/', { key: 'wrong-token' });
       assert.strictEqual(res.status, 403);
       assert.strictEqual(res.status, 403);
     });
     });
 
 
-    await test('GET / with valid key serves the screen', async () => {
+    await test('GET / with wrong key and valid cookie is rejected with 403', async () => {
+      const res = await get('/', { key: 'wrong-token', cookie: `${COOKIE_NAME}=${TOKEN}` });
+      assert.strictEqual(res.status, 403, 'explicit wrong query key must not fall back to cookie auth');
+    });
+
+    await test('GET / with valid query returns bootstrap instead of screen content', async () => {
+      const res = await get('/', { key: TOKEN });
+      assert.strictEqual(res.status, 200);
+      assert(res.body.includes('sessionStorage'), 'bootstrap should store the session key in tab storage');
+      assert(res.body.includes('location.replace'), 'bootstrap should navigate to the bare root URL');
+      assert(!res.body.includes('Secret screen'), 'bootstrap must not serve screen HTML at the keyed URL');
+    });
+
+    await test('bootstrap strips the key URL even when sessionStorage write fails', async () => {
       const res = await get('/', { key: TOKEN });
       const res = await get('/', { key: TOKEN });
       assert.strictEqual(res.status, 200);
       assert.strictEqual(res.status, 200);
-      assert(res.body.includes('Secret screen'), 'should serve the screen content');
+      let replacements;
+      assert.doesNotThrow(() => {
+        replacements = runBootstrapScript(res.body, {
+        setItem() { throw new Error('storage blocked'); }
+        });
+      });
+      assert.deepStrictEqual(replacements, ['/']);
+    });
+
+    await test('HTML responses include leak-reduction and anti-framing headers', async () => {
+      const res = await get('/', { key: TOKEN });
+      assert.strictEqual(res.status, 200);
+      assertSecurityHeaders(res.headers);
     });
     });
 
 
     await test('valid key load sets an HttpOnly SameSite=Strict cookie', async () => {
     await test('valid key load sets an HttpOnly SameSite=Strict cookie', async () => {
@@ -142,7 +198,8 @@ async function runTests() {
     await test('GET / with valid cookie (no query key) serves the screen', async () => {
     await test('GET / with valid cookie (no query key) serves the screen', async () => {
       const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
       const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
       assert.strictEqual(res.status, 200);
       assert.strictEqual(res.status, 200);
-      assert(res.body.includes('Secret screen'));
+      assert(res.body.includes('Secret screen'), 'cookie-authenticated bare root should serve the screen');
+      assert(!res.body.includes("location.replace('/');"), 'bare screen response should not be the bootstrap page');
     });
     });
 
 
     console.log('\n--- HTTP /files gate ---');
     console.log('\n--- HTTP /files gate ---');
@@ -158,6 +215,12 @@ async function runTests() {
       assert(res.body.includes('secret asset'));
       assert(res.body.includes('secret asset'));
     });
     });
 
 
+    await test('/files responses include leak-reduction and anti-framing headers', async () => {
+      const res = await get('/files/asset.txt', { key: TOKEN });
+      assert.strictEqual(res.status, 200);
+      assertSecurityHeaders(res.headers);
+    });
+
     console.log('\n--- WebSocket gate ---');
     console.log('\n--- WebSocket gate ---');
 
 
     await test('WS upgrade without key is rejected', async () => {
     await test('WS upgrade without key is rejected', async () => {
@@ -178,6 +241,33 @@ async function runTests() {
       assert.strictEqual(outcome, 'opened');
       assert.strictEqual(outcome, 'opened');
     });
     });
 
 
+    await test('WS upgrade with valid cookie and same-origin Origin opens', async () => {
+      const { outcome, ws } = await wsConnect({
+        cookie: `${COOKIE_NAME}=${TOKEN}`,
+        origin: `http://localhost:${TEST_PORT}`
+      });
+      ws.close();
+      assert.strictEqual(outcome, 'opened');
+    });
+
+    await test('WS upgrade with valid cookie but cross-origin Origin is rejected', async () => {
+      const eventsFile = path.join(TEST_DIR, 'state', 'events');
+      if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
+
+      const { outcome, ws } = await wsConnect({
+        cookie: `${COOKIE_NAME}=${TOKEN}`,
+        origin: 'http://localhost:9999'
+      });
+      if (outcome === 'opened') {
+        ws.send(JSON.stringify({ type: 'choice', choice: 'attacker-injected', text: 'local attacker probe' }));
+        await sleep(300);
+      }
+      ws.close();
+
+      assert.strictEqual(outcome, 'rejected', 'cross-origin browser WS must not open even with cookie');
+      assert(!fs.existsSync(eventsFile), 'cross-origin WS must not write state/events');
+    });
+
     console.log('\n--- Robustness (A3) ---');
     console.log('\n--- Robustness (A3) ---');
 
 
     await test('null payload over an authed WS does not crash the server', async () => {
     await test('null payload over an authed WS does not crash the server', async () => {

+ 38 - 4
tests/brainstorm-server/helper.test.js

@@ -85,7 +85,7 @@ console.log('\n--- Reconnect state machine (mocked browser) ---');
 // Drive helper.js's browser code against mocked DOM/WebSocket/timers/clock so we
 // Drive helper.js's browser code against mocked DOM/WebSocket/timers/clock so we
 // can exercise the actual reconnect/status/tombstone behaviour, not just grep it.
 // can exercise the actual reconnect/status/tombstone behaviour, not just grep it.
 function makeEnv() {
 function makeEnv() {
-  const state = { now: 1000, timers: [], reloads: 0, appended: [] };
+  const state = { now: 1000, timers: [], reloads: 0, replacements: [], appended: [], sessionKey: 'stored-key-abc' };
   const sockets = [];
   const sockets = [];
   const statusEl = { textContent: '', style: { setProperty() {} } };
   const statusEl = { textContent: '', style: { setProperty() {} } };
   class FakeWS {
   class FakeWS {
@@ -97,7 +97,14 @@ function makeEnv() {
   FakeWS.OPEN = 1;
   FakeWS.OPEN = 1;
   const env = {
   const env = {
     module: { exports: {} },
     module: { exports: {} },
-    window: { location: { host: 'localhost:7777', reload() { state.reloads++; } } },
+    window: {
+      location: {
+        host: 'localhost:7777',
+        reload() { state.reloads++; },
+        replace(url) { state.replacements.push(url); }
+      },
+      sessionStorage: { getItem: (key) => key === 'brainstorm-session-key' ? state.sessionKey : null }
+    },
     document: {
     document: {
       querySelector: (s) => s === '.status' ? statusEl : null,
       querySelector: (s) => s === '.status' ? statusEl : null,
       getElementById: () => null,
       getElementById: () => null,
@@ -124,6 +131,20 @@ function makeEnv() {
   };
   };
 }
 }
 
 
+test('uses sessionStorage key in the WebSocket URL when present', () => {
+  const e = makeEnv();
+  e.state.sessionKey = 'stored-key-abc';
+  e.boot();
+  assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777/?key=stored-key-abc');
+});
+
+test('uses cookie-only WebSocket URL when no sessionStorage key is present', () => {
+  const e = makeEnv();
+  e.state.sessionKey = null;
+  e.boot();
+  assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777');
+});
+
 test('on disconnect shows Reconnecting and schedules a 500ms reconnect', () => {
 test('on disconnect shows Reconnecting and schedules a 500ms reconnect', () => {
   const e = makeEnv(); e.boot();
   const e = makeEnv(); e.boot();
   e.last().open();
   e.last().open();
@@ -150,13 +171,26 @@ test('shows the tombstone and Disconnected after the grace period', () => {
   assert.strictEqual(e.state.appended.length, 1, 'tombstone appended exactly once');
   assert.strictEqual(e.state.appended.length, 1, 'tombstone appended exactly once');
 });
 });
 
 
-test('reloads to recover when a tombstoned connection comes back', () => {
+test('rebootstraps with stored key when a tombstoned connection comes back', () => {
   const e = makeEnv(); e.boot();
   const e = makeEnv(); e.boot();
   e.last().open(); e.last().close();
   e.last().open(); e.last().close();
   e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
   e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
-  assert.strictEqual(e.state.reloads, 0);
+  assert.deepStrictEqual(e.state.replacements, []);
   e.fireReconnect(); e.last().open();                    // server back (e.g. same-port restart)
   e.fireReconnect(); e.last().open();                    // server back (e.g. same-port restart)
+  assert.strictEqual(e.state.reloads, 0, 'stored-key recovery should not reload bare /');
+  assert.deepStrictEqual(e.state.replacements, ['/?key=stored-key-abc']);
+});
+
+test('reloads to recover when tombstoned and no sessionStorage key is present', () => {
+  const e = makeEnv();
+  e.state.sessionKey = null;
+  e.boot();
+  e.last().open(); e.last().close();
+  e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
+  assert.strictEqual(e.state.reloads, 0);
+  e.fireReconnect(); e.last().open();                    // server back (e.g. cookie-only page)
   assert.strictEqual(e.state.reloads, 1, 'reloads once on recovery');
   assert.strictEqual(e.state.reloads, 1, 'reloads once on recovery');
+  assert.deepStrictEqual(e.state.replacements, []);
 });
 });
 
 
 console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
 console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);

+ 60 - 2
tests/brainstorm-server/lifecycle.test.js

@@ -20,6 +20,19 @@ const START = path.join(__dirname, '../../skills/brainstorming/scripts/start-ser
 const STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
 const STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
 const sleep = ms => new Promise(r => setTimeout(r, ms));
 const sleep = ms => new Promise(r => setTimeout(r, ms));
 
 
+function waitForExit(child, timeoutMs = 2000) {
+  return new Promise(resolve => {
+    let settled = false;
+    const finish = (exited) => {
+      if (settled) return;
+      settled = true;
+      resolve(exited);
+    };
+    child.once('exit', () => finish(true));
+    setTimeout(() => finish(false), timeoutMs);
+  });
+}
+
 function firstServerStarted(out) {
 function firstServerStarted(out) {
   return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
   return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
 }
 }
@@ -72,7 +85,7 @@ async function runTests() {
   await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
   await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
     const dir = fs.mkdtempSync('/tmp/bs-life-');
     const dir = fs.mkdtempSync('/tmp/bs-life-');
     let info;
     let info;
-    const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5'], { encoding: 'utf8' });
+    const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });
     info = firstServerStarted(out);
     info = firstServerStarted(out);
     try {
     try {
       assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
       assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
@@ -94,7 +107,9 @@ async function runTests() {
     const infoA = firstServerStarted(outA);
     const infoA = firstServerStarted(outA);
     const keyA = new URL(infoA.url).searchParams.get('key');
     const keyA = new URL(infoA.url).searchParams.get('key');
     assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
     assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
-    a.kill(); await sleep(400); // free the port
+    const exitedA = waitForExit(a);
+    a.kill();
+    assert(await exitedA, 'first server should exit before restart binds its port');
 
 
     const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
     const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
     let outB = ''; b.stdout.on('data', d => outB += d.toString());
     let outB = ''; b.stdout.on('data', d => outB += d.toString());
@@ -108,6 +123,49 @@ async function runTests() {
     assert.strictEqual(keyB, keyA, 'restart should reuse the same session key');
     assert.strictEqual(keyB, keyA, 'restart should reuse the same session key');
   });
   });
 
 
+  await test('stored key can authenticate WebSocket after same-port restart', async () => {
+    const dir = fs.mkdtempSync('/tmp/bs-reconnect-');
+    const portFile = path.join(dir, '.last-port');
+    const tokenFile = path.join(dir, '.last-token');
+    const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
+    let a = null, b = null, ws = null;
+
+    try {
+      a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
+      let outA = ''; a.stdout.on('data', d => outA += d.toString());
+      for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
+      const infoA = firstServerStarted(outA);
+      const keyA = new URL(infoA.url).searchParams.get('key');
+      const exitedA = waitForExit(a);
+      a.kill();
+      assert(await exitedA, 'first server should exit before restart binds its port');
+      a = null;
+
+      b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
+      let outB = ''; b.stdout.on('data', d => outB += d.toString());
+      for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
+      const infoB = firstServerStarted(outB);
+
+      ws = new WebSocket(`ws://localhost:${infoB.port}/?key=${keyA}`, {
+        headers: { Origin: `http://localhost:${infoB.port}` }
+      });
+      const opened = await new Promise(resolve => {
+        ws.on('open', () => resolve(true));
+        ws.on('error', () => resolve(false));
+        setTimeout(() => resolve(false), 1500);
+      });
+
+      assert.strictEqual(infoB.port, infoA.port, 'restart should reuse same port');
+      assert(opened, 'stored key should authenticate WS after restart');
+    } finally {
+      try { if (ws) ws.close(); } catch (e) {}
+      try { if (a) a.kill(); } catch (e) {}
+      try { if (b) b.kill(); } catch (e) {}
+      await sleep(100);
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
   await test('falls back to a random port when the preferred port is taken', async () => {
   await test('falls back to a random port when the preferred port is taken', async () => {
     const dir = fs.mkdtempSync('/tmp/bs-port-');
     const dir = fs.mkdtempSync('/tmp/bs-port-');
     const portFile = path.join(dir, '.last-port');
     const portFile = path.join(dir, '.last-port');

+ 24 - 2
tests/brainstorm-server/server.test.js

@@ -35,9 +35,9 @@ async function sleep(ms) {
 }
 }
 
 
 async function fetch(url) {
 async function fetch(url) {
-  const authed = url + (url.includes('?') ? '&' : '?') + 'key=' + TOKEN;
   return new Promise((resolve, reject) => {
   return new Promise((resolve, reject) => {
-    http.get(authed, (res) => {
+    const headers = { Cookie: `brainstorm-key-${TEST_PORT}=${TOKEN}` };
+    http.get(url, { headers }, (res) => {
       let data = '';
       let data = '';
       res.on('data', chunk => data += chunk);
       res.on('data', chunk => data += chunk);
       res.on('end', () => resolve({
       res.on('end', () => resolve({
@@ -210,6 +210,28 @@ async function runTests() {
       assert.strictEqual(alive.status, 200, 'server must survive a /files/ request');
       assert.strictEqual(alive.status, 200, 'server must survive a /files/ request');
     });
     });
 
 
+    await test('does not serve symlinks that escape content dir via /files/', async () => {
+      const target = path.join(STATE_DIR, 'server-info');
+      const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
+      try { fs.unlinkSync(link); } catch (e) {}
+      fs.symlinkSync(target, link);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
+      assert.strictEqual(res.status, 404, 'symlink to state/server-info must not be served');
+      assert(!res.body.includes('server-started'), 'response must not include server-info body');
+    });
+
+    await test('does not serve hard links to files outside content dir via /files/', async () => {
+      const target = path.join(STATE_DIR, 'server-info');
+      const link = path.join(CONTENT_DIR, 'hard-linked-server-info.txt');
+      try { fs.unlinkSync(link); } catch (e) {}
+      fs.linkSync(target, link);
+
+      const res = await fetch(`http://localhost:${TEST_PORT}/files/hard-linked-server-info.txt`);
+      assert.strictEqual(res.status, 404, 'hard link to state/server-info must not be served');
+      assert(!res.body.includes('server-started'), 'response must not include server-info body');
+    });
+
     await test('returns 404 for non-root paths', async () => {
     await test('returns 404 for non-root paths', async () => {
       const res = await fetch(`http://localhost:${TEST_PORT}/other`);
       const res = await fetch(`http://localhost:${TEST_PORT}/other`);
       assert.strictEqual(res.status, 404);
       assert.strictEqual(res.status, 404);