mirror of
https://github.com/obra/superpowers.git
synced 2026-07-01 23:19:04 +08:00
Harden brainstorm companion auth regressions
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
.private-journal/
|
.private-journal/
|
||||||
.claude/
|
.claude/
|
||||||
|
.superpowers/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules/
|
node_modules/
|
||||||
inspo
|
inspo
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
// Everything below is browser-only; bail out when loaded in Node (tests).
|
// Everything below is browser-only; bail out when loaded in Node (tests).
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const WS_URL = 'ws://' + window.location.host;
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let eventQueue = [];
|
let eventQueue = [];
|
||||||
let reconnectDelay = MIN_RECONNECT_MS;
|
let reconnectDelay = MIN_RECONNECT_MS;
|
||||||
@@ -23,6 +22,27 @@
|
|||||||
let everConnected = false;
|
let everConnected = false;
|
||||||
let tombstoneShown = 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).
|
// Reflect connection state in the frame's status pill (absent on full-doc screens).
|
||||||
function setStatus(state) {
|
function setStatus(state) {
|
||||||
const el = document.querySelector('.status');
|
const el = document.querySelector('.status');
|
||||||
@@ -57,7 +77,7 @@
|
|||||||
function connect() {
|
function connect() {
|
||||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
setStatus(everConnected ? 'reconnecting' : 'connecting');
|
setStatus(everConnected ? 'reconnecting' : 'connecting');
|
||||||
ws = new WebSocket(WS_URL);
|
ws = new WebSocket(websocketUrl());
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const recovered = tombstoneShown;
|
const recovered = tombstoneShown;
|
||||||
@@ -69,8 +89,9 @@
|
|||||||
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||||
eventQueue = [];
|
eventQueue = [];
|
||||||
// Recovered from a tombstoned outage (e.g. the server restarted on the same
|
// Recovered from a tombstoned outage (e.g. the server restarted on the same
|
||||||
// port) — reload to pick up the restarted server's current screen.
|
// port) — reload through the keyed bootstrap when possible so the cookie is
|
||||||
if (recovered) window.location.reload();
|
// refreshed before the visible URL returns to bare /.
|
||||||
|
if (recovered) reloadAfterRecovery();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (msg) => {
|
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
|
<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>`;
|
<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 frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||||
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||||
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
||||||
@@ -178,6 +192,21 @@ function getNewestScreen() {
|
|||||||
return files.length > 0 ? files[0].path : null;
|
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 ==========
|
// ========== Authentication ==========
|
||||||
|
|
||||||
function timingSafeEqualStr(a, b) {
|
function timingSafeEqualStr(a, b) {
|
||||||
@@ -203,8 +232,11 @@ function parseCookies(header) {
|
|||||||
function isAuthorized(req) {
|
function isAuthorized(req) {
|
||||||
const q = req.url.indexOf('?');
|
const q = req.url.indexOf('?');
|
||||||
if (q >= 0) {
|
if (q >= 0) {
|
||||||
const key = new URLSearchParams(req.url.slice(q + 1)).get('key');
|
const params = new URLSearchParams(req.url.slice(q + 1));
|
||||||
if (key && timingSafeEqualStr(key, TOKEN)) return true;
|
if (params.has('key')) {
|
||||||
|
const key = params.get('key');
|
||||||
|
return Boolean(key && timingSafeEqualStr(key, TOKEN));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
|
const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
|
||||||
if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
|
if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
|
||||||
@@ -216,25 +248,53 @@ function pathnameOf(url) {
|
|||||||
return q >= 0 ? url.slice(0, q) : 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 ==========
|
// ========== HTTP Request Handler ==========
|
||||||
|
|
||||||
function handleRequest(req, res) {
|
function handleRequest(req, res) {
|
||||||
if (!isAuthorized(req)) {
|
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);
|
res.end(FORBIDDEN_PAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
touchActivity(); // only authorized requests count as activity
|
touchActivity(); // only authorized requests count as activity
|
||||||
|
|
||||||
// Mirror the key into a cookie so same-origin subresources (/files/*) and the
|
// Mirror the key into a cookie so same-origin subresources (/files/*) can
|
||||||
// WebSocket handshake carry it automatically, whatever URL style the agent
|
// authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
|
||||||
// writes. SameSite=Strict: a cross-site page can neither read the key nor ride
|
// WebSocket Origin check below is what blocks cross-origin localhost injection.
|
||||||
// the cookie; HttpOnly: page scripts can't exfiltrate it.
|
|
||||||
res.setHeader('Set-Cookie',
|
res.setHeader('Set-Cookie',
|
||||||
COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
|
COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
|
||||||
|
|
||||||
const pathname = pathnameOf(req.url);
|
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();
|
const screenFile = getNewestScreen();
|
||||||
let html = screenFile
|
let html = screenFile
|
||||||
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
||||||
@@ -246,24 +306,24 @@ function handleRequest(req, res) {
|
|||||||
html += helperInjection;
|
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);
|
res.end(html);
|
||||||
} else if (req.method === 'GET' && pathname.startsWith('/files/')) {
|
} else if (req.method === 'GET' && pathname.startsWith('/files/')) {
|
||||||
const fileName = path.basename(pathname.slice(7));
|
const fileName = path.basename(pathname.slice(7));
|
||||||
const filePath = path.join(CONTENT_DIR, fileName);
|
const filePath = path.join(CONTENT_DIR, fileName);
|
||||||
// Reject empty/dotfile names and anything that isn't a regular file —
|
// Reject empty/dotfile names and anything that isn't a regular file —
|
||||||
// `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
|
// `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
|
||||||
if (!fileName || fileName.startsWith('.') || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
|
||||||
res.writeHead(404);
|
res.writeHead(404, securityHeaders());
|
||||||
res.end('Not found');
|
res.end('Not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
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));
|
res.end(fs.readFileSync(filePath));
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404, securityHeaders());
|
||||||
res.end('Not found');
|
res.end('Not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +333,7 @@ function handleRequest(req, res) {
|
|||||||
const clients = new Set();
|
const clients = new Set();
|
||||||
|
|
||||||
function handleUpgrade(req, socket) {
|
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'];
|
const key = req.headers['sec-websocket-key'];
|
||||||
if (!key) { socket.destroy(); return; }
|
if (!key) { socket.destroy(); return; }
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ if [[ -f "$PID_FILE" ]]; then
|
|||||||
rm -f "$PID_FILE"
|
rm -f "$PID_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
|
|
||||||
# Resolve the harness PID (grandparent of this script).
|
# Resolve the harness PID (grandparent of this script).
|
||||||
# $PPID is the ephemeral shell the harness spawned to run us — it dies
|
# $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"
|
echo "$SERVER_PID" > "$PID_FILE"
|
||||||
|
|
||||||
# Wait for server-started message (check log 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
|
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
||||||
# Verify server is still alive after a short window (catches process reapers)
|
# Verify server is still alive after a short window (catches process reapers)
|
||||||
alive="true"
|
alive="true"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ if [[ -f "$PID_FILE" ]]; then
|
|||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
# Wait for graceful shutdown (up to ~2s)
|
# 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
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const TEST_DIR = '/tmp/brainstorm-auth-test';
|
|||||||
const CONTENT_DIR = path.join(TEST_DIR, 'content');
|
const CONTENT_DIR = path.join(TEST_DIR, 'content');
|
||||||
const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
|
const TOKEN = 'testtoken-0123456789abcdef0123456789abcdef';
|
||||||
const COOKIE_NAME = `brainstorm-key-${TEST_PORT}`;
|
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() {
|
function cleanup() {
|
||||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
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'.
|
// 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 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);
|
const ws = new WebSocket(url, opts);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
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) {
|
async function waitForServer(server) {
|
||||||
let stdout = '', stderr = '';
|
let stdout = '', stderr = '';
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -120,15 +145,46 @@ async function runTests() {
|
|||||||
assert(/key/i.test(res.body), '403 body should mention the key');
|
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 () => {
|
await test('GET / with wrong key is rejected with 403', async () => {
|
||||||
const res = await get('/', { key: 'wrong-token' });
|
const res = await get('/', { key: 'wrong-token' });
|
||||||
assert.strictEqual(res.status, 403);
|
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 });
|
const res = await get('/', { key: TOKEN });
|
||||||
assert.strictEqual(res.status, 200);
|
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 () => {
|
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 () => {
|
await test('GET / with valid cookie (no query key) serves the screen', async () => {
|
||||||
const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
|
const res = await get('/', { cookie: `${COOKIE_NAME}=${TOKEN}` });
|
||||||
assert.strictEqual(res.status, 200);
|
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 ---');
|
console.log('\n--- HTTP /files gate ---');
|
||||||
@@ -158,6 +215,12 @@ async function runTests() {
|
|||||||
assert(res.body.includes('secret asset'));
|
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 ---');
|
console.log('\n--- WebSocket gate ---');
|
||||||
|
|
||||||
await test('WS upgrade without key is rejected', async () => {
|
await test('WS upgrade without key is rejected', async () => {
|
||||||
@@ -178,6 +241,33 @@ async function runTests() {
|
|||||||
assert.strictEqual(outcome, 'opened');
|
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) ---');
|
console.log('\n--- Robustness (A3) ---');
|
||||||
|
|
||||||
await test('null payload over an authed WS does not crash the server', async () => {
|
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
|
// 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.
|
// can exercise the actual reconnect/status/tombstone behaviour, not just grep it.
|
||||||
function makeEnv() {
|
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 sockets = [];
|
||||||
const statusEl = { textContent: '', style: { setProperty() {} } };
|
const statusEl = { textContent: '', style: { setProperty() {} } };
|
||||||
class FakeWS {
|
class FakeWS {
|
||||||
@@ -97,7 +97,14 @@ function makeEnv() {
|
|||||||
FakeWS.OPEN = 1;
|
FakeWS.OPEN = 1;
|
||||||
const env = {
|
const env = {
|
||||||
module: { exports: {} },
|
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: {
|
document: {
|
||||||
querySelector: (s) => s === '.status' ? statusEl : null,
|
querySelector: (s) => s === '.status' ? statusEl : null,
|
||||||
getElementById: () => 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', () => {
|
test('on disconnect shows Reconnecting and schedules a 500ms reconnect', () => {
|
||||||
const e = makeEnv(); e.boot();
|
const e = makeEnv(); e.boot();
|
||||||
e.last().open();
|
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');
|
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();
|
const e = makeEnv(); e.boot();
|
||||||
e.last().open(); e.last().close();
|
e.last().open(); e.last().close();
|
||||||
e.advance(20000); e.fireReconnect(); e.last().close(); // tombstone now shown
|
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)
|
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.strictEqual(e.state.reloads, 1, 'reloads once on recovery');
|
||||||
|
assert.deepStrictEqual(e.state.replacements, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`\n--- Results: ${passed} passed, ${failed} failed ---`);
|
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 STOP = path.join(__dirname, '../../skills/brainstorming/scripts/stop-server.sh');
|
||||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
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) {
|
function firstServerStarted(out) {
|
||||||
return JSON.parse(out.trim().split('\n').find(l => l.includes('server-started')));
|
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 () => {
|
await test('start-server.sh --idle-timeout-minutes sets the timeout', async () => {
|
||||||
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
const dir = fs.mkdtempSync('/tmp/bs-life-');
|
||||||
let info;
|
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);
|
info = firstServerStarted(out);
|
||||||
try {
|
try {
|
||||||
assert.strictEqual(info.idle_timeout_ms, 5 * 60 * 1000, '5 minutes -> 300000 ms');
|
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 infoA = firstServerStarted(outA);
|
||||||
const keyA = new URL(infoA.url).searchParams.get('key');
|
const keyA = new URL(infoA.url).searchParams.get('key');
|
||||||
assert(fs.existsSync(portFile) && fs.existsSync(tokenFile), 'should write the port and token files');
|
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') } });
|
const b = spawn('node', [SERVER], { env: { ...env, BRAINSTORM_DIR: path.join(dir, 's2') } });
|
||||||
let outB = ''; b.stdout.on('data', d => outB += d.toString());
|
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');
|
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 () => {
|
await test('falls back to a random port when the preferred port is taken', async () => {
|
||||||
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
||||||
const portFile = path.join(dir, '.last-port');
|
const portFile = path.join(dir, '.last-port');
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ async function sleep(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetch(url) {
|
async function fetch(url) {
|
||||||
const authed = url + (url.includes('?') ? '&' : '?') + 'key=' + TOKEN;
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get(authed, (res) => {
|
const headers = { Cookie: `brainstorm-key-${TEST_PORT}=${TOKEN}` };
|
||||||
|
http.get(url, { headers }, (res) => {
|
||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', chunk => data += chunk);
|
res.on('data', chunk => data += chunk);
|
||||||
res.on('end', () => resolve({
|
res.on('end', () => resolve({
|
||||||
@@ -210,6 +210,28 @@ async function runTests() {
|
|||||||
assert.strictEqual(alive.status, 200, 'server must survive a /files/ request');
|
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 () => {
|
await test('returns 404 for non-root paths', async () => {
|
||||||
const res = await fetch(`http://localhost:${TEST_PORT}/other`);
|
const res = await fetch(`http://localhost:${TEST_PORT}/other`);
|
||||||
assert.strictEqual(res.status, 404);
|
assert.strictEqual(res.status, 404);
|
||||||
|
|||||||
Reference in New Issue
Block a user