mirror of
https://github.com/obra/superpowers.git
synced 2026-06-13 14:19:05 +08:00
Harden brainstorm companion auth regressions
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.worktrees/
|
||||
.private-journal/
|
||||
.claude/
|
||||
.superpowers/
|
||||
.DS_Store
|
||||
node_modules/
|
||||
inspo
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
// Everything below is browser-only; bail out when loaded in Node (tests).
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const WS_URL = 'ws://' + window.location.host;
|
||||
let ws = null;
|
||||
let eventQueue = [];
|
||||
let reconnectDelay = MIN_RECONNECT_MS;
|
||||
@@ -23,6 +22,27 @@
|
||||
let everConnected = false;
|
||||
let tombstoneShown = false;
|
||||
|
||||
function sessionKey() {
|
||||
try {
|
||||
return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function websocketUrl() {
|
||||
const key = sessionKey();
|
||||
return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
|
||||
}
|
||||
|
||||
function reloadAfterRecovery() {
|
||||
const key = sessionKey();
|
||||
if (key) {
|
||||
window.location.replace('/?key=' + encodeURIComponent(key));
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Reflect connection state in the frame's status pill (absent on full-doc screens).
|
||||
function setStatus(state) {
|
||||
const el = document.querySelector('.status');
|
||||
@@ -57,7 +77,7 @@
|
||||
function connect() {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
setStatus(everConnected ? 'reconnecting' : 'connecting');
|
||||
ws = new WebSocket(WS_URL);
|
||||
ws = new WebSocket(websocketUrl());
|
||||
|
||||
ws.onopen = () => {
|
||||
const recovered = tombstoneShown;
|
||||
@@ -69,8 +89,9 @@
|
||||
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||
eventQueue = [];
|
||||
// Recovered from a tombstoned outage (e.g. the server restarted on the same
|
||||
// port) — reload to pick up the restarted server's current screen.
|
||||
if (recovered) window.location.reload();
|
||||
// port) — reload through the keyed bootstrap when possible so the cookie is
|
||||
// refreshed before the visible URL returns to bare /.
|
||||
if (recovered) reloadAfterRecovery();
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
|
||||
@@ -152,6 +152,20 @@ h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1e
|
||||
<p>This page needs the full URL your coding agent gave you, including the
|
||||
<code>?key=…</code> part. Copy the complete URL and open it again.</p></body></html>`;
|
||||
|
||||
function bootstrapPage(key) {
|
||||
const jsonKey = JSON.stringify(String(key));
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
|
||||
<body>
|
||||
<script>
|
||||
try { sessionStorage.setItem('brainstorm-session-key', ${jsonKey}); } catch (e) {}
|
||||
location.replace('/');
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
||||
@@ -178,6 +192,21 @@ function getNewestScreen() {
|
||||
return files.length > 0 ? files[0].path : null;
|
||||
}
|
||||
|
||||
function isRegularFileInsideContentDir(filePath) {
|
||||
let stat, realContentDir, realFilePath;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
if (stat.isSymbolicLink()) return false;
|
||||
if (!stat.isFile()) return false;
|
||||
if (stat.nlink !== 1) return false;
|
||||
realContentDir = fs.realpathSync(CONTENT_DIR);
|
||||
realFilePath = fs.realpathSync(filePath);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return realFilePath.startsWith(realContentDir + path.sep);
|
||||
}
|
||||
|
||||
// ========== Authentication ==========
|
||||
|
||||
function timingSafeEqualStr(a, b) {
|
||||
@@ -203,8 +232,11 @@ function parseCookies(header) {
|
||||
function isAuthorized(req) {
|
||||
const q = req.url.indexOf('?');
|
||||
if (q >= 0) {
|
||||
const key = new URLSearchParams(req.url.slice(q + 1)).get('key');
|
||||
if (key && timingSafeEqualStr(key, TOKEN)) return true;
|
||||
const params = new URLSearchParams(req.url.slice(q + 1));
|
||||
if (params.has('key')) {
|
||||
const key = params.get('key');
|
||||
return Boolean(key && timingSafeEqualStr(key, TOKEN));
|
||||
}
|
||||
}
|
||||
const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
|
||||
if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
|
||||
@@ -216,25 +248,53 @@ function pathnameOf(url) {
|
||||
return q >= 0 ? url.slice(0, q) : url;
|
||||
}
|
||||
|
||||
function queryKey(url) {
|
||||
const q = url.indexOf('?');
|
||||
if (q < 0) return null;
|
||||
return new URLSearchParams(url.slice(q + 1)).get('key');
|
||||
}
|
||||
|
||||
function securityHeaders(headers = {}) {
|
||||
return {
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||
'Cross-Origin-Resource-Policy': 'same-origin',
|
||||
...headers
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedWebSocketOrigin(req) {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin) return true;
|
||||
const host = req.headers.host;
|
||||
if (!host) return false;
|
||||
return origin === 'http://' + host;
|
||||
}
|
||||
|
||||
// ========== HTTP Request Handler ==========
|
||||
|
||||
function handleRequest(req, res) {
|
||||
if (!isAuthorized(req)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||
res.end(FORBIDDEN_PAGE);
|
||||
return;
|
||||
}
|
||||
touchActivity(); // only authorized requests count as activity
|
||||
|
||||
// Mirror the key into a cookie so same-origin subresources (/files/*) and the
|
||||
// WebSocket handshake carry it automatically, whatever URL style the agent
|
||||
// writes. SameSite=Strict: a cross-site page can neither read the key nor ride
|
||||
// the cookie; HttpOnly: page scripts can't exfiltrate it.
|
||||
// Mirror the key into a cookie so same-origin subresources (/files/*) can
|
||||
// authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
|
||||
// WebSocket Origin check below is what blocks cross-origin localhost injection.
|
||||
res.setHeader('Set-Cookie',
|
||||
COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
|
||||
|
||||
const pathname = pathnameOf(req.url);
|
||||
if (req.method === 'GET' && pathname === '/') {
|
||||
const keyFromQuery = queryKey(req.url);
|
||||
if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
|
||||
res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||
res.end(bootstrapPage(keyFromQuery));
|
||||
} else if (req.method === 'GET' && pathname === '/') {
|
||||
const screenFile = getNewestScreen();
|
||||
let html = screenFile
|
||||
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
||||
@@ -246,24 +306,24 @@ function handleRequest(req, res) {
|
||||
html += helperInjection;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||
res.end(html);
|
||||
} else if (req.method === 'GET' && pathname.startsWith('/files/')) {
|
||||
const fileName = path.basename(pathname.slice(7));
|
||||
const filePath = path.join(CONTENT_DIR, fileName);
|
||||
// Reject empty/dotfile names and anything that isn't a regular file —
|
||||
// `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
|
||||
if (!fileName || fileName.startsWith('.') || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
res.writeHead(404);
|
||||
if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
|
||||
res.writeHead(404, securityHeaders());
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
|
||||
res.end(fs.readFileSync(filePath));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.writeHead(404, securityHeaders());
|
||||
res.end('Not found');
|
||||
}
|
||||
}
|
||||
@@ -273,7 +333,7 @@ function handleRequest(req, res) {
|
||||
const clients = new Set();
|
||||
|
||||
function handleUpgrade(req, socket) {
|
||||
if (!isAuthorized(req)) { socket.destroy(); return; }
|
||||
if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
|
||||
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) { socket.destroy(); return; }
|
||||
|
||||
@@ -125,7 +125,7 @@ if [[ -f "$PID_FILE" ]]; then
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
cd "$SCRIPT_DIR" || exit 1
|
||||
|
||||
# Resolve the harness PID (grandparent of this script).
|
||||
# $PPID is the ephemeral shell the harness spawned to run us — it dies
|
||||
@@ -163,7 +163,7 @@ disown "$SERVER_PID" 2>/dev/null
|
||||
echo "$SERVER_PID" > "$PID_FILE"
|
||||
|
||||
# Wait for server-started message (check log file)
|
||||
for i in {1..50}; do
|
||||
for _ in {1..50}; do
|
||||
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
||||
# Verify server is still alive after a short window (catches process reapers)
|
||||
alive="true"
|
||||
|
||||
@@ -54,7 +54,7 @@ if [[ -f "$PID_FILE" ]]; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
# Wait for graceful shutdown (up to ~2s)
|
||||
for i in {1..20}; do
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -85,7 +85,7 @@ console.log('\n--- Reconnect state machine (mocked browser) ---');
|
||||
// Drive helper.js's browser code against mocked DOM/WebSocket/timers/clock so we
|
||||
// can exercise the actual reconnect/status/tombstone behaviour, not just grep it.
|
||||
function makeEnv() {
|
||||
const state = { now: 1000, timers: [], reloads: 0, appended: [] };
|
||||
const state = { now: 1000, timers: [], reloads: 0, replacements: [], appended: [], sessionKey: 'stored-key-abc' };
|
||||
const sockets = [];
|
||||
const statusEl = { textContent: '', style: { setProperty() {} } };
|
||||
class FakeWS {
|
||||
@@ -97,7 +97,14 @@ function makeEnv() {
|
||||
FakeWS.OPEN = 1;
|
||||
const env = {
|
||||
module: { exports: {} },
|
||||
window: { location: { host: 'localhost:7777', reload() { state.reloads++; } } },
|
||||
window: {
|
||||
location: {
|
||||
host: 'localhost:7777',
|
||||
reload() { state.reloads++; },
|
||||
replace(url) { state.replacements.push(url); }
|
||||
},
|
||||
sessionStorage: { getItem: (key) => key === 'brainstorm-session-key' ? state.sessionKey : null }
|
||||
},
|
||||
document: {
|
||||
querySelector: (s) => s === '.status' ? statusEl : null,
|
||||
getElementById: () => null,
|
||||
@@ -124,6 +131,20 @@ function makeEnv() {
|
||||
};
|
||||
}
|
||||
|
||||
test('uses sessionStorage key in the WebSocket URL when present', () => {
|
||||
const e = makeEnv();
|
||||
e.state.sessionKey = 'stored-key-abc';
|
||||
e.boot();
|
||||
assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777/?key=stored-key-abc');
|
||||
});
|
||||
|
||||
test('uses cookie-only WebSocket URL when no sessionStorage key is present', () => {
|
||||
const e = makeEnv();
|
||||
e.state.sessionKey = null;
|
||||
e.boot();
|
||||
assert.strictEqual(e.sockets[0].url, 'ws://localhost:7777');
|
||||
});
|
||||
|
||||
test('on disconnect shows Reconnecting and schedules a 500ms reconnect', () => {
|
||||
const e = makeEnv(); e.boot();
|
||||
e.last().open();
|
||||
@@ -150,13 +171,26 @@ test('shows the tombstone and Disconnected after the grace period', () => {
|
||||
assert.strictEqual(e.state.appended.length, 1, 'tombstone appended exactly once');
|
||||
});
|
||||
|
||||
test('reloads to recover when a tombstoned connection comes back', () => {
|
||||
test('rebootstraps with stored key when a tombstoned connection comes back', () => {
|
||||
const e = makeEnv(); e.boot();
|
||||
e.last().open(); e.last().close();
|
||||
e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
|
||||
assert.strictEqual(e.state.reloads, 0);
|
||||
assert.deepStrictEqual(e.state.replacements, []);
|
||||
e.fireReconnect(); e.last().open(); // server back (e.g. same-port restart)
|
||||
assert.strictEqual(e.state.reloads, 0, 'stored-key recovery should not reload bare /');
|
||||
assert.deepStrictEqual(e.state.replacements, ['/?key=stored-key-abc']);
|
||||
});
|
||||
|
||||
test('reloads to recover when tombstoned and no sessionStorage key is present', () => {
|
||||
const e = makeEnv();
|
||||
e.state.sessionKey = null;
|
||||
e.boot();
|
||||
e.last().open(); e.last().close();
|
||||
e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
|
||||
assert.strictEqual(e.state.reloads, 0);
|
||||
e.fireReconnect(); e.last().open(); // server back (e.g. cookie-only page)
|
||||
assert.strictEqual(e.state.reloads, 1, 'reloads once on recovery');
|
||||
assert.deepStrictEqual(e.state.replacements, []);
|
||||
});
|
||||
|
||||
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
||||
|
||||
@@ -20,6 +20,19 @@ const START = path.join(__dirname, '../../skills/brainstorming/scripts/start-ser
|
||||
const STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function waitForExit(child, timeoutMs = 2000) {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (exited) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(exited);
|
||||
};
|
||||
child.once('exit', () => finish(true));
|
||||
setTimeout(() => finish(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
function firstServerStarted(out) {
|
||||
return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
|
||||
}
|
||||
@@ -72,7 +85,7 @@ async function runTests() {
|
||||
await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
||||
let info;
|
||||
const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5'], { encoding: 'utf8' });
|
||||
const out = execFileSync('bash', [START, '--project-dir', dir, '--idle-timeout-minutes', '5', '--background'], { encoding: 'utf8' });
|
||||
info = firstServerStarted(out);
|
||||
try {
|
||||
assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
|
||||
@@ -94,7 +107,9 @@ async function runTests() {
|
||||
const infoA = firstServerStarted(outA);
|
||||
const keyA = new URL(infoA.url).searchParams.get('key');
|
||||
assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
|
||||
a.kill(); await sleep(400); // free the port
|
||||
const exitedA = waitForExit(a);
|
||||
a.kill();
|
||||
assert(await exitedA, 'first server should exit before restart binds its port');
|
||||
|
||||
const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
|
||||
let outB = ''; b.stdout.on('data', d => outB += d.toString());
|
||||
@@ -108,6 +123,49 @@ async function runTests() {
|
||||
assert.strictEqual(keyB, keyA, 'restart should reuse the same session key');
|
||||
});
|
||||
|
||||
await test('stored key can authenticate WebSocket after same-port restart', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-reconnect-');
|
||||
const portFile = path.join(dir, '.last-port');
|
||||
const tokenFile = path.join(dir, '.last-token');
|
||||
const env = { ...process.env, BRAINSTORM_PORT_FILE: portFile, BRAINSTORM_TOKEN_FILE: tokenFile, BRAINSTORM_LIFECYCLE_CHECK_MS: 100000 };
|
||||
let a = null, b = null, ws = null;
|
||||
|
||||
try {
|
||||
a = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's1') } });
|
||||
let outA = ''; a.stdout.on('data', d => outA += d.toString());
|
||||
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
|
||||
const infoA = firstServerStarted(outA);
|
||||
const keyA = new URL(infoA.url).searchParams.get('key');
|
||||
const exitedA = waitForExit(a);
|
||||
a.kill();
|
||||
assert(await exitedA, 'first server should exit before restart binds its port');
|
||||
a = null;
|
||||
|
||||
b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
|
||||
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);
|
||||
|
||||
ws = new WebSocket(`ws://localhost:${infoB.port}/?key=${keyA}`, {
|
||||
headers: { Origin: `http://localhost:${infoB.port}` }
|
||||
});
|
||||
const opened = await new Promise(resolve => {
|
||||
ws.on('open', () => resolve(true));
|
||||
ws.on('error', () => resolve(false));
|
||||
setTimeout(() => resolve(false), 1500);
|
||||
});
|
||||
|
||||
assert.strictEqual(infoB.port, infoA.port, 'restart should reuse same port');
|
||||
assert(opened, 'stored key should authenticate WS after restart');
|
||||
} finally {
|
||||
try { if (ws) ws.close(); } catch (e) {}
|
||||
try { if (a) a.kill(); } catch (e) {}
|
||||
try { if (b) b.kill(); } catch (e) {}
|
||||
await sleep(100);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await test('falls back to a random port when the preferred port is taken', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
||||
const portFile = path.join(dir, '.last-port');
|
||||
|
||||
@@ -35,9 +35,9 @@ async function sleep(ms) {
|
||||
}
|
||||
|
||||
async function fetch(url) {
|
||||
const authed = url + (url.includes('?') ? '&' : '?') + 'key=' + TOKEN;
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(authed, (res) => {
|
||||
const headers = { Cookie: `brainstorm-key-${TEST_PORT}=${TOKEN}` };
|
||||
http.get(url, { headers }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({
|
||||
@@ -210,6 +210,28 @@ async function runTests() {
|
||||
assert.strictEqual(alive.status, 200, 'server must survive a /files/ request');
|
||||
});
|
||||
|
||||
await test('does not serve symlinks that escape content dir via /files/', async () => {
|
||||
const target = path.join(STATE_DIR, 'server-info');
|
||||
const link = path.join(CONTENT_DIR, 'linked-server-info.txt');
|
||||
try { fs.unlinkSync(link); } catch (e) {}
|
||||
fs.symlinkSync(target, link);
|
||||
|
||||
const res = await fetch(`http://localhost:${TEST_PORT}/files/linked-server-info.txt`);
|
||||
assert.strictEqual(res.status, 404, 'symlink to state/server-info must not be served');
|
||||
assert(!res.body.includes('server-started'), 'response must not include server-info body');
|
||||
});
|
||||
|
||||
await test('does not serve hard links to files outside content dir via /files/', async () => {
|
||||
const target = path.join(STATE_DIR, 'server-info');
|
||||
const link = path.join(CONTENT_DIR, 'hard-linked-server-info.txt');
|
||||
try { fs.unlinkSync(link); } catch (e) {}
|
||||
fs.linkSync(target, link);
|
||||
|
||||
const res = await fetch(`http://localhost:${TEST_PORT}/files/hard-linked-server-info.txt`);
|
||||
assert.strictEqual(res.status, 404, 'hard link to state/server-info must not be served');
|
||||
assert(!res.body.includes('server-started'), 'response must not include server-info body');
|
||||
});
|
||||
|
||||
await test('returns 404 for non-root paths', async () => {
|
||||
const res = await fetch(`http://localhost:${TEST_PORT}/other`);
|
||||
assert.strictEqual(res.status, 404);
|
||||
|
||||
Reference in New Issue
Block a user