diff --git a/skills/brainstorming/scripts/server.cjs b/skills/brainstorming/scripts/server.cjs index 22af144d..86024f05 100644 --- a/skills/brainstorming/scripts/server.cjs +++ b/skills/brainstorming/scripts/server.cjs @@ -110,8 +110,20 @@ let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_ // remote binds — and defeats DNS rebinding — where a Host/Origin allowlist // cannot. It rides the served URL as ?key= and is mirrored into a cookie on // first load so same-origin subresources and the WebSocket carry it for free. -const TOKEN = process.env.BRAINSTORM_TOKEN || crypto.randomBytes(32).toString('hex'); -const COOKIE_NAME = 'brainstorm-key-' + PORT; +// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the +// same key and an already-open tab's cookie still validates. +const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null; +const TOKEN = (() => { + if (process.env.BRAINSTORM_TOKEN) return process.env.BRAINSTORM_TOKEN; + if (TOKEN_FILE) { + try { + const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim(); + if (/^[0-9a-f]{32,}$/i.test(t)) return t; + } catch (e) { /* no prior token recorded */ } + } + return crypto.randomBytes(32).toString('hex'); +})(); +let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', @@ -207,13 +219,12 @@ function pathnameOf(url) { // ========== HTTP Request Handler ========== function handleRequest(req, res) { - touchActivity(); - if (!isAuthorized(req)) { res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(FORBIDDEN_PAGE); return; } + 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 @@ -240,7 +251,9 @@ function handleRequest(req, res) { } else if (req.method === 'GET' && pathname.startsWith('/files/')) { const fileName = path.basename(pathname.slice(7)); const filePath = path.join(CONTENT_DIR, fileName); - if (fileName.startsWith('.') || !fs.existsSync(filePath)) { + // Reject empty/dotfile names and anything that isn't a regular file — + // `/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); res.end('Not found'); return; @@ -351,7 +364,7 @@ function maybeOpenBrowser() { if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion if (HOST !== '127.0.0.1' && HOST !== 'localhost') return; if (clients.size > 0) return; // the user already opened it - const url = 'http://' + URL_HOST + ':' + PORT; + const url = 'http://' + URL_HOST + ':' + PORT + '/?key=' + TOKEN; // must carry the key or the gate 403s it const cp = require('child_process'); // Operator-provided launcher: run as given (this env var is trusted operator input). if (process.env.BRAINSTORM_OPEN_CMD) { @@ -483,12 +496,19 @@ function startServer() { let triedFallback = false; function onListen() { - // Record the bound port so the next restart of this session reuses it — but - // ONLY when we got our preferred port. On a fallback we bound a *different* - // port because someone else holds the preferred one; persisting it would - // overwrite the shared .last-port and strand that other session's open tab. + // Cookie name keys on the ACTUAL bound port (may differ from the preferred + // one after an EADDRINUSE fallback) so it can't collide with another server's + // cookie in the shared localhost jar. + COOKIE_NAME = 'brainstorm-key-' + PORT; + // Record the bound port AND token so the next restart of this session reuses + // them — but ONLY when we got our preferred port. On a fallback we bound a + // *different* port because someone else holds the preferred one; persisting + // would overwrite the shared files and strand that other session's open tab. if (PORT_FILE && !triedFallback) { try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ } + if (TOKEN_FILE) { + try { fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 }); } catch (e) { /* best effort */ } + } } const info = JSON.stringify({ type: 'server-started', port: Number(PORT), host: HOST, @@ -496,7 +516,8 @@ function startServer() { screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS }); console.log(info); - fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n'); + // server-info embeds the key — keep it owner-only. + fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 }); } server.on('error', (err) => { diff --git a/skills/brainstorming/scripts/start-server.sh b/skills/brainstorming/scripts/start-server.sh index d4e5724a..5e9a00cb 100755 --- a/skills/brainstorming/scripts/start-server.sh +++ b/skills/brainstorming/scripts/start-server.sh @@ -94,14 +94,19 @@ if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then fi fi +# Session files (server.log, server-info, .last-token) embed the session key — +# keep everything this script and the server create owner-only. +umask 077 + # Generate unique session directory SESSION_ID="$$-$(date +%s)" if [[ -n "$PROJECT_DIR" ]]; then SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}" - # Persist the bound port per project so a restart reuses it and an already-open - # browser tab reconnects to the same URL. + # Persist the bound port and key per project so a restart reuses them and an + # already-open browser tab reconnects to the same URL with a valid cookie. export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port" + export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token" else SESSION_DIR="/tmp/brainstorm-${SESSION_ID}" fi diff --git a/tests/brainstorm-server/lifecycle.test.js b/tests/brainstorm-server/lifecycle.test.js index 9203322f..cd186a7d 100644 --- a/tests/brainstorm-server/lifecycle.test.js +++ b/tests/brainstorm-server/lifecycle.test.js @@ -82,26 +82,30 @@ async function runTests() { } }); - await test('persists the bound port and restores it on restart', async () => { + await test('persists the bound port AND key, and restores both on restart', async () => { const dir = fs.mkdtempSync('/tmp/bs-port-'); const portFile = path.join(dir, '.last-port'); - const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 }; + const tokenFile = path.join(dir, '.last-token'); + const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 }; const 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 portA = firstServerStarted(outA).port; - assert(fs.existsSync(portFile), 'should write the port file'); - assert.strictEqual(Number(fs.readFileSync(portFile, 'utf8').trim()), portA, 'port file holds the bound port'); + const infoA = firstServerStarted(outA); + const keyA = new URL(infoA.url).searchParams.get('key'); + assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files'); a.kill(); await sleep(400); // free the port const 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 portB = firstServerStarted(outB).port; + const infoB = firstServerStarted(outB); + const keyB = new URL(infoB.url).searchParams.get('key'); b.kill(); await sleep(100); fs.rmSync(dir, { recursive: true, force: true }); - assert.strictEqual(portB, portA, 'restart should reuse the same port'); + assert.strictEqual(infoB.port, infoA.port, 'restart should reuse the same port'); + // Same key too — otherwise the open tab's cookie would 403 against the restart. + assert.strictEqual(keyB, keyA, 'restart should reuse the same session key'); }); await test('falls back to a random port when the preferred port is taken', async () => { @@ -143,12 +147,19 @@ async function runTests() { fs.writeFileSync(path.join(dir, 'content', 'second.html'), '