Quellcode durchsuchen

Isolate companion fallback tokens

Drew Ritter vor 3 Monaten
Ursprung
Commit
8f2525a803
2 geänderte Dateien mit 133 neuen und 5 gelöschten Zeilen
  1. 23 5
      skills/brainstorming/scripts/server.cjs
  2. 110 0
      tests/brainstorm-server/lifecycle.test.js

+ 23 - 5
skills/brainstorming/scripts/server.cjs

@@ -113,16 +113,26 @@ let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_
 // Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
 // same key and an already-open tab's cookie still validates.
 const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
-const TOKEN = (() => {
-  if (process.env.BRAINSTORM_TOKEN) return process.env.BRAINSTORM_TOKEN;
+function generateToken() {
+  return crypto.randomBytes(32).toString('hex');
+}
+
+function initialToken() {
+  if (process.env.BRAINSTORM_TOKEN) {
+    return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
+  }
   if (TOKEN_FILE) {
     try {
       const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
-      if (/^[0-9a-f]{32,}$/i.test(t)) return t;
+      if (/^[0-9a-f]{32,}$/i.test(t)) return { value: t, source: 'file' };
     } catch (e) { /* no prior token recorded */ }
   }
-  return crypto.randomBytes(32).toString('hex');
-})();
+  return { value: generateToken(), source: 'generated' };
+}
+
+const tokenInfo = initialToken();
+let TOKEN = tokenInfo.value;
+let tokenSource = tokenInfo.source;
 let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
 
 const MIME_TYPES = {
@@ -594,8 +604,16 @@ function startServer() {
 
   server.on('error', (err) => {
     if (err.code === 'EADDRINUSE' && !triedFallback) {
+      if (tokenSource === 'env') {
+        console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
+        process.exit(1);
+      }
       triedFallback = true;
       PORT = randomPort();
+      if (tokenSource === 'file') {
+        TOKEN = generateToken();
+        tokenSource = 'generated-fallback';
+      }
       server.listen(PORT, HOST, onListen);
     } else {
       console.error('Server failed to bind:', err.message);

+ 110 - 0
tests/brainstorm-server/lifecycle.test.js

@@ -66,6 +66,18 @@ function openCaptureCommand(dir, marker) {
   return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(markerPath)}`;
 }
 
+function httpStatus(port, key) {
+  return new Promise(resolve => {
+    const pathWithKey = key ? '/?key=' + encodeURIComponent(key) : '/';
+    require('http')
+      .get({ hostname: '127.0.0.1', port, path: pathWithKey }, res => {
+        res.resume();
+        resolve(res.statusCode);
+      })
+      .on('error', () => resolve(0));
+  });
+}
+
 async function runTests() {
   let passed = 0, failed = 0;
   async function test(name, fn) {
@@ -246,6 +258,104 @@ async function runTests() {
     assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port');
   });
 
+  await test('fallback with persisted token generates a fresh unpersisted key', async () => {
+    const dir = fs.mkdtempSync('/tmp/bs-port-');
+    const portFile = path.join(dir, '.last-port');
+    const tokenFile = path.join(dir, '.last-token');
+    const preferredToken = 'abababababababababababababababab';
+    let a = null, b = null;
+
+    try {
+      a = spawn('node', [SERVER], {
+        env: {
+          ...process.env,
+          BRAINSTORM_DIR: path.join(dir, 'a'),
+          BRAINSTORM_PORT: 3422,
+          BRAINSTORM_TOKEN: preferredToken,
+          BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
+        }
+      });
+      let outA = ''; a.stdout.on('data', d => outA += d.toString());
+      for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
+      assert(outA.includes('server-started'), 'preferred-port server should start');
+
+      fs.writeFileSync(portFile, '3422');
+      fs.writeFileSync(tokenFile, preferredToken, { mode: 0o600 });
+
+      b = spawn('node', [SERVER], {
+        env: {
+          ...process.env,
+          BRAINSTORM_DIR: path.join(dir, 'b'),
+          BRAINSTORM_PORT_FILE: portFile,
+          BRAINSTORM_TOKEN_FILE: tokenFile,
+          BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
+        }
+      });
+      let outB = ''; b.stdout.on('data', d => outB += d.toString());
+      for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
+      const infoB = firstServerStarted(outB);
+      const fallbackKey = new URL(infoB.url).searchParams.get('key');
+      const persistedAfter = fs.readFileSync(tokenFile, 'utf8').trim();
+      const originalStatus = await httpStatus(3422, fallbackKey);
+
+      assert.notStrictEqual(infoB.port, 3422, 'fallback should use a different port');
+      assert.notStrictEqual(fallbackKey, preferredToken, 'fallback must not reuse persisted key');
+      assert.strictEqual(persistedAfter, preferredToken, 'fallback must not overwrite .last-token');
+      assert.strictEqual(originalStatus, 403, 'fallback key must not authenticate to original server');
+    } finally {
+      await killAndWait(a);
+      await killAndWait(b);
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  await test('fallback with explicit BRAINSTORM_TOKEN fails closed', async () => {
+    const dir = fs.mkdtempSync('/tmp/bs-port-');
+    const portFile = path.join(dir, '.last-port');
+    const explicitToken = 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd';
+    let a = null, b = null;
+
+    try {
+      a = spawn('node', [SERVER], {
+        env: {
+          ...process.env,
+          BRAINSTORM_DIR: path.join(dir, 'a'),
+          BRAINSTORM_PORT: 3423,
+          BRAINSTORM_TOKEN: explicitToken,
+          BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
+        }
+      });
+      let outA = ''; a.stdout.on('data', d => outA += d.toString());
+      for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
+      assert(outA.includes('server-started'), 'preferred-port server should start');
+
+      fs.writeFileSync(portFile, '3423');
+      b = spawn('node', [SERVER], {
+        env: {
+          ...process.env,
+          BRAINSTORM_DIR: path.join(dir, 'b'),
+          BRAINSTORM_PORT_FILE: portFile,
+          BRAINSTORM_TOKEN: explicitToken,
+          BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
+        }
+      });
+      let outB = ''; let errB = '';
+      b.stdout.on('data', d => outB += d.toString());
+      b.stderr.on('data', d => errB += d.toString());
+      for (let i = 0; i < 60 && !outB.includes('server-started') && b.exitCode === null; i++) await sleep(50);
+      const exited = await waitForExit(b, 1500);
+
+      assert(exited, 'explicit-token fallback process should exit');
+      assert.notStrictEqual(b.exitCode, 0, 'explicit-token fallback should fail non-zero');
+      assert(!outB.includes('server-started'), 'explicit-token fallback must not start on a random port');
+      assert(/BRAINSTORM_TOKEN/.test(errB), `stderr should explain explicit token fallback refusal, got: ${errB}`);
+    } finally {
+      await killAndWait(a);
+      await killAndWait(b);
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
   await test('auto-opens the browser once, on the first screen', async () => {
     const dir = fs.mkdtempSync('/tmp/bs-open-');
     const marker = path.join(dir, 'opened.log');