feat(brainstorm-server): gate every endpoint behind a per-session key

The companion server is reachable by any local browser tab (default loopback
bind) and by any host that can route to it (remote --host bind). It served
screens, files, and accepted event-injecting WebSocket connections with no
authentication, so a malicious browser tab or a direct remote client could read
brainstorm content or inject events that the agent reads as the user's input
(prompt injection into a live session).

Generate a per-session secret token, carry it in the served URL as ?key=, and
mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so
same-origin subresources and the WebSocket handshake authenticate automatically.
Every HTTP request and WebSocket upgrade now requires a valid key (query or
cookie, constant-time compared); unauthenticated requests get a friendly 403
explaining they need the full URL. A secret authenticates the client uniformly
across loopback, tunnel, and remote binds and defeats DNS rebinding, which a
Host/Origin allowlist cannot.

Also guard handleMessage against a null JSON payload that crashed the process.

Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus
cookie bootstrap and the null-payload guard; server.test.js threads the key;
ws-protocol.test.js + auth.test.js wired into npm test.

Closes #1014
Refs #1110, #1553, #1504
This commit is contained in:
Jesse Vincent
2026-06-09 12:22:53 -07:00
parent 3e3c10e671
commit e3fe480b29
5 changed files with 308 additions and 20 deletions

View File

@@ -20,6 +20,9 @@ const TEST_PORT = 3334;
const TEST_DIR = '/tmp/brainstorm-test';
const CONTENT_DIR = path.join(TEST_DIR, 'content');
const STATE_DIR = path.join(TEST_DIR, 'state');
// Fixed session key so the test client can authenticate (see auth.test.js for
// the security behavior itself; here we just need authorized requests).
const TOKEN = 'testtoken-server-0123456789abcdef';
function cleanup() {
if (fs.existsSync(TEST_DIR)) {
@@ -32,8 +35,9 @@ async function sleep(ms) {
}
async function fetch(url) {
const authed = url + (url.includes('?') ? '&' : '?') + 'key=' + TOKEN;
return new Promise((resolve, reject) => {
http.get(url, (res) => {
http.get(authed, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve({
@@ -47,7 +51,7 @@ async function fetch(url) {
function startServer() {
return spawn('node', [SERVER_PATH], {
env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR }
env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR, BRAINSTORM_TOKEN: TOKEN }
});
}
@@ -207,7 +211,7 @@ async function runTests() {
console.log('\n--- WebSocket Communication ---');
await test('accepts WebSocket upgrade on /', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise((resolve, reject) => {
ws.on('open', resolve);
ws.on('error', reject);
@@ -217,7 +221,7 @@ async function runTests() {
await test('relays user events to stdout with source field', async () => {
stdoutAccum = '';
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'click', text: 'Test Button' }));
@@ -233,7 +237,7 @@ async function runTests() {
const eventsFile = path.join(STATE_DIR, 'events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'click', choice: 'b', text: 'Option B' }));
@@ -251,7 +255,7 @@ async function runTests() {
const eventsFile = path.join(STATE_DIR, 'events');
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
ws.send(JSON.stringify({ type: 'hover', text: 'Something' }));
@@ -263,8 +267,8 @@ async function runTests() {
});
await test('handles multiple concurrent WebSocket clients', async () => {
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
const ws2 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await Promise.all([
new Promise(resolve => ws1.on('open', resolve)),
new Promise(resolve => ws2.on('open', resolve))
@@ -289,7 +293,7 @@ async function runTests() {
});
await test('cleans up closed clients from broadcast list', async () => {
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws1 = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws1.on('open', resolve));
ws1.close();
await sleep(100);
@@ -301,7 +305,7 @@ async function runTests() {
});
await test('handles malformed JSON from client gracefully', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
// Send invalid JSON — server should not crash
@@ -318,7 +322,7 @@ async function runTests() {
console.log('\n--- File Watching ---');
await test('sends reload on new .html file', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
let gotReload = false;
@@ -338,7 +342,7 @@ async function runTests() {
fs.writeFileSync(filePath, '<h2>Original</h2>');
await sleep(500);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
let gotReload = false;
@@ -354,7 +358,7 @@ async function runTests() {
});
await test('does NOT send reload for non-.html files', async () => {
const ws = new WebSocket(`ws://localhost:${TEST_PORT}`);
const ws = new WebSocket(`ws://localhost:${TEST_PORT}/?key=${TOKEN}`);
await new Promise(resolve => ws.on('open', resolve));
let gotReload = false;