auth.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. /**
  2. * Security tests for the brainstorm server's per-session key.
  3. *
  4. * The companion server is reachable by any local browser tab (default loopback
  5. * bind) and by any host that can route to it (remote `--host 0.0.0.0` bind).
  6. * A per-session secret key gates every endpoint so that neither a browser
  7. * confused-deputy nor a direct remote client can read screens/files or inject
  8. * events into state/events (prompt injection into a live agent session).
  9. *
  10. * Auth = a valid `?key=<token>` query param OR a valid session cookie.
  11. *
  12. * Uses the `ws` npm package as a test client (test-only dependency).
  13. */
  14. const { spawn } = require('child_process');
  15. const http = require('http');
  16. const WebSocket = require('ws');
  17. const fs = require('fs');
  18. const path = require('path');
  19. const assert = require('assert');
  20. const SERVER_PATH = path.join(__dirname, '../../skills/brainstorming/scripts/server.cjs');
  21. const TEST_PORT = 3335;
  22. const TEST_DIR = '/tmp/brainstorm-auth-test';
  23. const CONTENT_DIR = path.join(TEST_DIR, 'content');
  24. const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
  25. const COOKIE_NAME = `brainstorm-key-${TEST_PORT}`;
  26. const EXPECTED_SECURITY_HEADERS = {
  27. 'referrer-policy': 'no-referrer',
  28. 'cache-control': 'no-store',
  29. 'x-frame-options': 'DENY',
  30. 'content-security-policy': "frame-ancestors 'none'",
  31. 'cross-origin-resource-policy': 'same-origin'
  32. };
  33. function cleanup() {
  34. if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
  35. }
  36. async function sleep(ms) {
  37. return new Promise(resolve => setTimeout(resolve, ms));
  38. }
  39. // Raw HTTP GET with optional key query and Cookie header.
  40. function get(pathname, { key, cookie } = {}) {
  41. const url = `http://localhost:${TEST_PORT}${pathname}` + (key !== undefined ? `?key=${key}` : '');
  42. const headers = {};
  43. if (cookie) headers['Cookie'] = cookie;
  44. return new Promise((resolve, reject) => {
  45. http.get(url, { headers }, (res) => {
  46. let data = '';
  47. res.on('data', chunk => data += chunk);
  48. res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
  49. }).on('error', reject);
  50. });
  51. }
  52. // Try to open a WebSocket; resolve 'opened' or 'rejected'.
  53. function wsConnect({ key, cookie, origin } = {}) {
  54. const url = `ws://localhost:${TEST_PORT}/` + (key !== undefined ? `?key=${key}` : '');
  55. const headers = {};
  56. if (cookie) headers['Cookie'] = cookie;
  57. if (origin) headers['Origin'] = origin;
  58. const opts = Object.keys(headers).length ? { headers } : {};
  59. const ws = new WebSocket(url, opts);
  60. return new Promise((resolve) => {
  61. let settled = false;
  62. const done = (outcome) => { if (!settled) { settled = true; resolve({ outcome, ws }); } };
  63. ws.on('open', () => done('opened'));
  64. ws.on('error', () => done('rejected'));
  65. ws.on('close', () => done('rejected'));
  66. setTimeout(() => done('rejected'), 1500);
  67. });
  68. }
  69. function startServer() {
  70. return spawn('node', [SERVER_PATH], {
  71. env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR, BRAINSTORM_TOKEN: TOKEN }
  72. });
  73. }
  74. function assertSecurityHeaders(headers) {
  75. for (const [name, value] of Object.entries(EXPECTED_SECURITY_HEADERS)) {
  76. assert.strictEqual(headers[name], value, `${name} should be ${value}`);
  77. }
  78. }
  79. function runBootstrapScript(html, sessionStorage) {
  80. const match = html.match(/<script>\n([\s\S]*?)\n<\/script>/);
  81. assert(match, 'bootstrap response should contain a script block');
  82. const replacements = [];
  83. const location = { replace(url) { replacements.push(url); } };
  84. new Function('sessionStorage', 'location', match[1])(sessionStorage, location);
  85. return replacements;
  86. }
  87. async function waitForServer(server) {
  88. let stdout = '', stderr = '';
  89. return new Promise((resolve, reject) => {
  90. server.stdout.on('data', (d) => {
  91. stdout += d.toString();
  92. if (stdout.includes('server-started')) resolve({ stdout });
  93. });
  94. server.stderr.on('data', (d) => { stderr += d.toString(); });
  95. server.on('error', reject);
  96. setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
  97. });
  98. }
  99. function serverStartedMessage(out) {
  100. const line = out.trim().split('\n').find(l => l.includes('server-started'));
  101. assert(line, 'server-started JSON should be present');
  102. return JSON.parse(line);
  103. }
  104. function assertStartedOnExpectedPort(out) {
  105. const msg = serverStartedMessage(out);
  106. assert.strictEqual(
  107. msg.port,
  108. TEST_PORT,
  109. `auth.test.js expected fixed port ${TEST_PORT}, got ${msg.port}; fixed-port tests must not run through fallback`
  110. );
  111. return msg;
  112. }
  113. async function runTests() {
  114. cleanup();
  115. fs.mkdirSync(CONTENT_DIR, { recursive: true });
  116. fs.writeFileSync(path.join(CONTENT_DIR, 'screen.html'), '<h2>Secret screen</h2>');
  117. fs.writeFileSync(path.join(CONTENT_DIR, 'asset.txt'), 'secret asset');
  118. const server = startServer();
  119. let stdoutAccum = '';
  120. server.stdout.on('data', (d) => { stdoutAccum += d.toString(); });
  121. let passed = 0, failed = 0;
  122. async function test(name, fn) {
  123. try { await fn(); console.log(` PASS: ${name}`); passed++; }
  124. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  125. }
  126. try {
  127. const { stdout: initialStdout } = await waitForServer(server);
  128. assertStartedOnExpectedPort(initialStdout);
  129. console.log('\n--- Startup URL ---');
  130. await test('server-started url includes the session key', () => {
  131. const msg = serverStartedMessage(initialStdout);
  132. assert(msg.url.includes(`key=${TOKEN}`), `url should carry the key, got: ${msg.url}`);
  133. });
  134. console.log('\n--- HTTP / gate ---');
  135. await test('GET / without key is rejected with 403', async () => {
  136. const res = await get('/');
  137. assert.strictEqual(res.status, 403, 'no-key request must be 403');
  138. });
  139. await test('403 page names "coding agent" and the key', async () => {
  140. const res = await get('/');
  141. assert(/coding agent/i.test(res.body), '403 body should reference the coding agent');
  142. assert(/key/i.test(res.body), '403 body should mention the key');
  143. });
  144. await test('403 responses include leak-reduction and anti-framing headers', async () => {
  145. const res = await get('/');
  146. assert.strictEqual(res.status, 403);
  147. assertSecurityHeaders(res.headers);
  148. });
  149. await test('GET / with wrong key is rejected with 403', async () => {
  150. const res = await get('/', { key: 'wrong-token' });
  151. assert.strictEqual(res.status, 403);
  152. });
  153. await test('GET / with wrong key and valid cookie is rejected with 403', async () => {
  154. const res = await get('/', { key: 'wrong-token', cookie: `${COOKIE_NAME}=${TOKEN}` });
  155. assert.strictEqual(res.status, 403, 'explicit wrong query key must not fall back to cookie auth');
  156. });
  157. await test('GET / with valid query returns bootstrap instead of screen content', async () => {
  158. const res = await get('/', { key: TOKEN });
  159. assert.strictEqual(res.status, 200);
  160. assert(res.body.includes('sessionStorage'), 'bootstrap should store the session key in tab storage');
  161. assert(res.body.includes('location.replace'), 'bootstrap should navigate to the bare root URL');
  162. assert(!res.body.includes('Secret screen'), 'bootstrap must not serve screen HTML at the keyed URL');
  163. });
  164. await test('bootstrap strips the key URL even when sessionStorage write fails', async () => {
  165. const res = await get('/', { key: TOKEN });
  166. assert.strictEqual(res.status, 200);
  167. let replacements;
  168. assert.doesNotThrow(() => {
  169. replacements = runBootstrapScript(res.body, {
  170. setItem() { throw new Error('storage blocked'); }
  171. });
  172. });
  173. assert.deepStrictEqual(replacements, ['/']);
  174. });
  175. await test('HTML responses include leak-reduction and anti-framing headers', async () => {
  176. const res = await get('/', { key: TOKEN });
  177. assert.strictEqual(res.status, 200);
  178. assertSecurityHeaders(res.headers);
  179. });
  180. await test('valid key load sets an HttpOnly SameSite=Strict cookie', async () => {
  181. const res = await get('/', { key: TOKEN });
  182. const setCookie = (res.headers['set-cookie'] || []).join('; ');
  183. assert(setCookie.includes(`${COOKIE_NAME}=${TOKEN}`), `should set ${COOKIE_NAME}`);
  184. assert(/HttpOnly/i.test(setCookie), 'cookie should be HttpOnly');
  185. assert(/SameSite=Strict/i.test(setCookie), 'cookie should be SameSite=Strict');
  186. });
  187. await test('GET / with valid cookie (no query key) serves the screen', async () => {
  188. const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
  189. assert.strictEqual(res.status, 200);
  190. assert(res.body.includes('Secret screen'), 'cookie-authenticated bare root should serve the screen');
  191. assert(!res.body.includes("location.replace('/');"), 'bare screen response should not be the bootstrap page');
  192. });
  193. console.log('\n--- HTTP /files gate ---');
  194. await test('GET /files without key is rejected with 403', async () => {
  195. const res = await get('/files/asset.txt');
  196. assert.strictEqual(res.status, 403);
  197. });
  198. await test('GET /files with valid key serves the file', async () => {
  199. const res = await get('/files/asset.txt', { key: TOKEN });
  200. assert.strictEqual(res.status, 200);
  201. assert(res.body.includes('secret asset'));
  202. });
  203. await test('/files responses include leak-reduction and anti-framing headers', async () => {
  204. const res = await get('/files/asset.txt', { key: TOKEN });
  205. assert.strictEqual(res.status, 200);
  206. assertSecurityHeaders(res.headers);
  207. });
  208. console.log('\n--- WebSocket gate ---');
  209. await test('WS upgrade without key is rejected', async () => {
  210. const { outcome, ws } = await wsConnect();
  211. ws.close();
  212. assert.strictEqual(outcome, 'rejected', 'unauthenticated WS must not open');
  213. });
  214. await test('WS upgrade with valid key opens', async () => {
  215. const { outcome, ws } = await wsConnect({ key: TOKEN });
  216. ws.close();
  217. assert.strictEqual(outcome, 'opened');
  218. });
  219. await test('WS upgrade with valid cookie opens', async () => {
  220. const { outcome, ws } = await wsConnect({ cookie: `${COOKIE_NAME}=${TOKEN}` });
  221. ws.close();
  222. assert.strictEqual(outcome, 'opened');
  223. });
  224. await test('WS upgrade with valid cookie and same-origin Origin opens', async () => {
  225. const { outcome, ws } = await wsConnect({
  226. cookie: `${COOKIE_NAME}=${TOKEN}`,
  227. origin: `http://localhost:${TEST_PORT}`
  228. });
  229. ws.close();
  230. assert.strictEqual(outcome, 'opened');
  231. });
  232. await test('WS upgrade with valid cookie but cross-origin Origin is rejected', async () => {
  233. const eventsFile = path.join(TEST_DIR, 'state', 'events');
  234. if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
  235. const { outcome, ws } = await wsConnect({
  236. cookie: `${COOKIE_NAME}=${TOKEN}`,
  237. origin: 'http://localhost:9999'
  238. });
  239. if (outcome === 'opened') {
  240. ws.send(JSON.stringify({ type: 'choice', choice: 'attacker-injected', text: 'local attacker probe' }));
  241. await sleep(300);
  242. }
  243. ws.close();
  244. assert.strictEqual(outcome, 'rejected', 'cross-origin browser WS must not open even with cookie');
  245. assert(!fs.existsSync(eventsFile), 'cross-origin WS must not write state/events');
  246. });
  247. console.log('\n--- Robustness (A3) ---');
  248. await test('null payload over an authed WS does not crash the server', async () => {
  249. const { ws } = await wsConnect({ key: TOKEN });
  250. ws.send('null');
  251. await sleep(300);
  252. const res = await get('/', { key: TOKEN });
  253. assert.strictEqual(res.status, 200, 'server must still respond after null payload');
  254. ws.close();
  255. });
  256. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  257. if (failed > 0) {
  258. process.exitCode = 1;
  259. return;
  260. }
  261. } finally {
  262. server.kill();
  263. await sleep(100);
  264. cleanup();
  265. }
  266. }
  267. runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });