Harden brainstorm companion auth regressions

This commit is contained in:
Drew Ritter
2026-06-10 14:58:16 -07:00
committed by Drew Ritter
parent fe812c418f
commit a2e67bbd9b
9 changed files with 320 additions and 34 deletions

View File

@@ -25,6 +25,13 @@ const TEST_DIR = '/tmp/brainstorm-auth-test';
const CONTENT_DIR = path.join(TEST_DIR, 'content');
const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
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() {
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'.
function wsConnect({ key, cookie } = {}) {
function wsConnect({ key, cookie, origin } = {}) {
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);
return new Promise((resolve) => {
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) {
let stdout = '', stderr = '';
return new Promise((resolve, reject) => {
@@ -120,15 +145,46 @@ async function runTests() {
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 () => {
const res = await get('/', { key: 'wrong-token' });
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('Secret screen'), 'should serve the screen content');
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 });
assert.strictEqual(res.status, 200);
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 () => {
@@ -142,7 +198,8 @@ async function runTests() {
await test('GET / with valid cookie (no query key) serves the screen', async () => {
const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
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 ---');
@@ -158,6 +215,12 @@ async function runTests() {
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 ---');
await test('WS upgrade without key is rejected', async () => {
@@ -178,6 +241,33 @@ async function runTests() {
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) ---');
await test('null payload over an authed WS does not crash the server', async () => {