auth.test.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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. function cleanup() {
  27. if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
  28. }
  29. async function sleep(ms) {
  30. return new Promise(resolve => setTimeout(resolve, ms));
  31. }
  32. // Raw HTTP GET with optional key query and Cookie header.
  33. function get(pathname, { key, cookie } = {}) {
  34. const url = `http://localhost:${TEST_PORT}${pathname}` + (key !== undefined ? `?key=${key}` : '');
  35. const headers = {};
  36. if (cookie) headers['Cookie'] = cookie;
  37. return new Promise((resolve, reject) => {
  38. http.get(url, { headers }, (res) => {
  39. let data = '';
  40. res.on('data', chunk => data += chunk);
  41. res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data }));
  42. }).on('error', reject);
  43. });
  44. }
  45. // Try to open a WebSocket; resolve 'opened' or 'rejected'.
  46. function wsConnect({ key, cookie } = {}) {
  47. const url = `ws://localhost:${TEST_PORT}/` + (key !== undefined ? `?key=${key}` : '');
  48. const opts = cookie ? { headers: { Cookie: cookie } } : {};
  49. const ws = new WebSocket(url, opts);
  50. return new Promise((resolve) => {
  51. let settled = false;
  52. const done = (outcome) => { if (!settled) { settled = true; resolve({ outcome, ws }); } };
  53. ws.on('open', () => done('opened'));
  54. ws.on('error', () => done('rejected'));
  55. ws.on('close', () => done('rejected'));
  56. setTimeout(() => done('rejected'), 1500);
  57. });
  58. }
  59. function startServer() {
  60. return spawn('node', [SERVER_PATH], {
  61. env: { ...process.env, BRAINSTORM_PORT: TEST_PORT, BRAINSTORM_DIR: TEST_DIR, BRAINSTORM_TOKEN: TOKEN }
  62. });
  63. }
  64. async function waitForServer(server) {
  65. let stdout = '', stderr = '';
  66. return new Promise((resolve, reject) => {
  67. server.stdout.on('data', (d) => {
  68. stdout += d.toString();
  69. if (stdout.includes('server-started')) resolve({ stdout });
  70. });
  71. server.stderr.on('data', (d) => { stderr += d.toString(); });
  72. server.on('error', reject);
  73. setTimeout(() => reject(new Error(`Server didn't start. stderr: ${stderr}`)), 5000);
  74. });
  75. }
  76. async function runTests() {
  77. cleanup();
  78. fs.mkdirSync(CONTENT_DIR, { recursive: true });
  79. fs.writeFileSync(path.join(CONTENT_DIR, 'screen.html'), '<h2>Secret screen</h2>');
  80. fs.writeFileSync(path.join(CONTENT_DIR, 'asset.txt'), 'secret asset');
  81. const server = startServer();
  82. let stdoutAccum = '';
  83. server.stdout.on('data', (d) => { stdoutAccum += d.toString(); });
  84. const { stdout: initialStdout } = await waitForServer(server);
  85. let passed = 0, failed = 0;
  86. async function test(name, fn) {
  87. try { await fn(); console.log(` PASS: ${name}`); passed++; }
  88. catch (e) { console.log(` FAIL: ${name}`); console.log(` ${e.message}`); failed++; }
  89. }
  90. try {
  91. console.log('\n--- Startup URL ---');
  92. await test('server-started url includes the session key', () => {
  93. const msg = JSON.parse(initialStdout.trim());
  94. assert(msg.url.includes(`key=${TOKEN}`), `url should carry the key, got: ${msg.url}`);
  95. });
  96. console.log('\n--- HTTP / gate ---');
  97. await test('GET / without key is rejected with 403', async () => {
  98. const res = await get('/');
  99. assert.strictEqual(res.status, 403, 'no-key request must be 403');
  100. });
  101. await test('403 page names "coding agent" and the key', async () => {
  102. const res = await get('/');
  103. assert(/coding agent/i.test(res.body), '403 body should reference the coding agent');
  104. assert(/key/i.test(res.body), '403 body should mention the key');
  105. });
  106. await test('GET / with wrong key is rejected with 403', async () => {
  107. const res = await get('/', { key: 'wrong-token' });
  108. assert.strictEqual(res.status, 403);
  109. });
  110. await test('GET / with valid key serves the screen', async () => {
  111. const res = await get('/', { key: TOKEN });
  112. assert.strictEqual(res.status, 200);
  113. assert(res.body.includes('Secret screen'), 'should serve the screen content');
  114. });
  115. await test('valid key load sets an HttpOnly SameSite=Strict cookie', async () => {
  116. const res = await get('/', { key: TOKEN });
  117. const setCookie = (res.headers['set-cookie'] || []).join('; ');
  118. assert(setCookie.includes(`${COOKIE_NAME}=${TOKEN}`), `should set ${COOKIE_NAME}`);
  119. assert(/HttpOnly/i.test(setCookie), 'cookie should be HttpOnly');
  120. assert(/SameSite=Strict/i.test(setCookie), 'cookie should be SameSite=Strict');
  121. });
  122. await test('GET / with valid cookie (no query key) serves the screen', async () => {
  123. const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
  124. assert.strictEqual(res.status, 200);
  125. assert(res.body.includes('Secret screen'));
  126. });
  127. console.log('\n--- HTTP /files gate ---');
  128. await test('GET /files without key is rejected with 403', async () => {
  129. const res = await get('/files/asset.txt');
  130. assert.strictEqual(res.status, 403);
  131. });
  132. await test('GET /files with valid key serves the file', async () => {
  133. const res = await get('/files/asset.txt', { key: TOKEN });
  134. assert.strictEqual(res.status, 200);
  135. assert(res.body.includes('secret asset'));
  136. });
  137. console.log('\n--- WebSocket gate ---');
  138. await test('WS upgrade without key is rejected', async () => {
  139. const { outcome, ws } = await wsConnect();
  140. ws.close();
  141. assert.strictEqual(outcome, 'rejected', 'unauthenticated WS must not open');
  142. });
  143. await test('WS upgrade with valid key opens', async () => {
  144. const { outcome, ws } = await wsConnect({ key: TOKEN });
  145. ws.close();
  146. assert.strictEqual(outcome, 'opened');
  147. });
  148. await test('WS upgrade with valid cookie opens', async () => {
  149. const { outcome, ws } = await wsConnect({ cookie: `${COOKIE_NAME}=${TOKEN}` });
  150. ws.close();
  151. assert.strictEqual(outcome, 'opened');
  152. });
  153. console.log('\n--- Robustness (A3) ---');
  154. await test('null payload over an authed WS does not crash the server', async () => {
  155. const { ws } = await wsConnect({ key: TOKEN });
  156. ws.send('null');
  157. await sleep(300);
  158. const res = await get('/', { key: TOKEN });
  159. assert.strictEqual(res.status, 200, 'server must still respond after null payload');
  160. ws.close();
  161. });
  162. console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
  163. if (failed > 0) process.exit(1);
  164. } finally {
  165. server.kill();
  166. await sleep(100);
  167. cleanup();
  168. }
  169. }
  170. runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });