mirror of
https://github.com/obra/superpowers.git
synced 2026-06-11 21:29:07 +08:00
feat(brainstorm-server): gate every endpoint behind a per-session key
The companion server is reachable by any local browser tab (default loopback bind) and by any host that can route to it (remote --host bind). It served screens, files, and accepted event-injecting WebSocket connections with no authentication, so a malicious browser tab or a direct remote client could read brainstorm content or inject events that the agent reads as the user's input (prompt injection into a live session). Generate a per-session secret token, carry it in the served URL as ?key=, and mirror it into an HttpOnly SameSite=Strict per-port cookie on first load so same-origin subresources and the WebSocket handshake authenticate automatically. Every HTTP request and WebSocket upgrade now requires a valid key (query or cookie, constant-time compared); unauthenticated requests get a friendly 403 explaining they need the full URL. A secret authenticates the client uniformly across loopback, tunnel, and remote binds and defeats DNS rebinding, which a Host/Origin allowlist cannot. Also guard handleMessage against a null JSON payload that crashed the process. Tests: new auth.test.js (13 cases) covering the key on /, /files/*, and WS plus cookie bootstrap and the null-payload guard; server.test.js threads the key; ws-protocol.test.js + auth.test.js wired into npm test. Closes #1014 Refs #1110, #1553, #1504
This commit is contained in:
@@ -104,6 +104,15 @@ const CONTENT_DIR = path.join(SESSION_DIR, 'content');
|
||||
const STATE_DIR = path.join(SESSION_DIR, 'state');
|
||||
let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
|
||||
|
||||
// Per-session secret key. The companion is reachable by any local browser tab
|
||||
// and, when bound to a non-loopback host, by any host that can route to it.
|
||||
// The key authenticates the real client uniformly across loopback, tunnel, and
|
||||
// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist
|
||||
// cannot. It rides the served URL as ?key= and is mirrored into a cookie on
|
||||
// first load so same-origin subresources and the WebSocket carry it for free.
|
||||
const TOKEN = process.env.BRAINSTORM_TOKEN || crypto.randomBytes(32).toString('hex');
|
||||
const COOKIE_NAME = 'brainstorm-key-' + PORT;
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
@@ -121,6 +130,16 @@ h1 { color: #333; } p { color: #666; }</style>
|
||||
<body><h1>Brainstorm Companion</h1>
|
||||
<p>Waiting for the agent to push a screen...</p></body></html>`;
|
||||
|
||||
const FORBIDDEN_PAGE = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Session key required</title>
|
||||
<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||
h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1em 0.3em; border-radius: 4px; }</style>
|
||||
</head>
|
||||
<body><h1>Session key required</h1>
|
||||
<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>`;
|
||||
|
||||
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>';
|
||||
@@ -147,11 +166,64 @@ function getNewestScreen() {
|
||||
return files.length > 0 ? files[0].path : null;
|
||||
}
|
||||
|
||||
// ========== Authentication ==========
|
||||
|
||||
function timingSafeEqualStr(a, b) {
|
||||
const ab = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
function parseCookies(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A request is authorized if it carries the session key as ?key= or as the
|
||||
// session cookie. Both are compared in constant time.
|
||||
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 cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
|
||||
if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function pathnameOf(url) {
|
||||
const q = url.indexOf('?');
|
||||
return q >= 0 ? url.slice(0, q) : url;
|
||||
}
|
||||
|
||||
// ========== HTTP Request Handler ==========
|
||||
|
||||
function handleRequest(req, res) {
|
||||
touchActivity();
|
||||
if (req.method === 'GET' && req.url === '/') {
|
||||
|
||||
if (!isAuthorized(req)) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(FORBIDDEN_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
res.setHeader('Set-Cookie',
|
||||
COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
|
||||
|
||||
const pathname = pathnameOf(req.url);
|
||||
if (req.method === 'GET' && pathname === '/') {
|
||||
const screenFile = getNewestScreen();
|
||||
let html = screenFile
|
||||
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
||||
@@ -165,8 +237,8 @@ function handleRequest(req, res) {
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
} else if (req.method === 'GET' && req.url.startsWith('/files/')) {
|
||||
const fileName = path.basename(req.url.slice(7));
|
||||
} else if (req.method === 'GET' && pathname.startsWith('/files/')) {
|
||||
const fileName = path.basename(pathname.slice(7));
|
||||
const filePath = path.join(CONTENT_DIR, fileName);
|
||||
if (fileName.startsWith('.') || !fs.existsSync(filePath)) {
|
||||
res.writeHead(404);
|
||||
@@ -188,6 +260,8 @@ function handleRequest(req, res) {
|
||||
const clients = new Set();
|
||||
|
||||
function handleUpgrade(req, socket) {
|
||||
if (!isAuthorized(req)) { socket.destroy(); return; }
|
||||
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) { socket.destroy(); return; }
|
||||
|
||||
@@ -254,7 +328,7 @@ function handleMessage(text) {
|
||||
}
|
||||
touchActivity();
|
||||
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
||||
if (event.choice) {
|
||||
if (event && event.choice) {
|
||||
const eventsFile = path.join(STATE_DIR, 'events');
|
||||
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
||||
}
|
||||
@@ -418,7 +492,7 @@ function startServer() {
|
||||
}
|
||||
const info = JSON.stringify({
|
||||
type: 'server-started', port: Number(PORT), host: HOST,
|
||||
url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
|
||||
url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT + '/?key=' + TOKEN,
|
||||
screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS
|
||||
});
|
||||
console.log(info);
|
||||
|
||||
Reference in New Issue
Block a user