|
|
@@ -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('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);
|
|
|
- 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 () => {
|
|
|
@@ -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 () => {
|