mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 06:26:45 +02:00
13a7528697
Activity stream and inspector events SSE endpoints accepted the root
AUTH_TOKEN via `?token=` query param (EventSource can't send Authorization
headers). URLs leak to browser history, referer headers, server logs,
crash reports, and refactoring accidents. Codex flagged this during the
/plan-ceo-review outside voice pass.
New auth model: the extension calls POST /sse-session with a Bearer token
and receives a view-only session cookie (HttpOnly, SameSite=Strict, 30-min
TTL). EventSource is opened with `withCredentials: true` so the browser
sends the cookie back on the SSE connection. The ?token= query param is
GONE — no more URL-borne secrets.
Scope isolation (prior learning cookie-picker-auth-isolation, 10/10
confidence): the SSE session cookie grants access to /activity/stream and
/inspector/events ONLY. The token is never valid against /command, /token,
or any mutating endpoint. A leaked cookie can watch activity; it cannot
execute browser commands.
Components
* browse/src/sse-session-cookie.ts — registry: mint/validate/extract/
build-cookie. 256-bit tokens, 30-min TTL, lazy expiry pruning,
no imports from token-registry (scope isolation enforced by module
boundary).
* browse/src/server.ts — POST /sse-session mint endpoint (requires
Bearer). /activity/stream and /inspector/events now accept Bearer
OR the session cookie, and reject ?token= query param.
* extension/sidepanel.js — ensureSseSessionCookie() bootstrap call,
EventSource opened with withCredentials:true on both SSE endpoints.
Tested via the source guards; behavioral test is the E2E pairing
flow that lands later in the wave.
* browse/test/sse-session-cookie.test.ts — 20 unit tests covering
mint entropy, TTL enforcement, cookie flag invariants, cookie
parsing from multi-cookie headers, and scope-isolation contract
guard (module must not import token-registry).
* browse/test/server-auth.test.ts — existing /activity/stream auth
test updated to assert the new cookie-based gate and the absence
of the ?token= query param.
Cookie flag choices:
* HttpOnly: token not readable from page JS (mitigates XSS
exfiltration).
* SameSite=Strict: cookie not sent on cross-site requests (mitigates
CSRF). Fine for SSE because the extension connects to 127.0.0.1
directly.
* Path=/: cookie scoped to the whole origin.
* Max-Age=1800: 30 minutes, matches TTL. Extension re-mints on
reconnect when daemon restarts.
* Secure NOT set: daemon binds to 127.0.0.1 over plain HTTP. Adding
Secure would block the browser from ever sending the cookie back.
Add Secure when gstack ships over HTTPS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
/**
|
|
* View-only session cookie registry for SSE endpoints.
|
|
*
|
|
* Why this exists: EventSource cannot send Authorization headers, so
|
|
* /activity/stream and /inspector/events historically took a `?token=`
|
|
* query param with the root AUTH_TOKEN. URLs leak through browser history,
|
|
* referer headers, server logs, crash reports, and refactoring accidents
|
|
* (Codex's plan-review outside voice called this out). This module issues
|
|
* a separate short-lived token, scoped to SSE reads only, delivered via
|
|
* an HttpOnly SameSite=Strict cookie that EventSource can pick up with
|
|
* `withCredentials: true`.
|
|
*
|
|
* Design notes:
|
|
* - TTL 30 minutes. Long enough for a normal coding session; short enough
|
|
* that a leaked cookie expires quickly.
|
|
* - Scope is implicit: validating a cookie only grants read access to
|
|
* /activity/stream and /inspector/events. The cookie is NEVER valid on
|
|
* /command, /token, or any mutating endpoint. Matches the
|
|
* cookie-picker-auth-isolation pattern (prior learning, 10/10 confidence):
|
|
* cookie-based session tokens must not be valid as scoped tokens.
|
|
* - In-memory only. No persistence across daemon restarts — extension
|
|
* re-mints on reconnect.
|
|
* - Tokens are 32 random bytes (URL-safe base64). 256 bits, unbruteforceable.
|
|
*/
|
|
import * as crypto from 'crypto';
|
|
|
|
interface Session {
|
|
createdAt: number;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
const sessions = new Map<string, Session>();
|
|
|
|
export const SSE_COOKIE_NAME = 'gstack_sse';
|
|
|
|
/** Mint a fresh view-only SSE session token. */
|
|
export function mintSseSessionToken(): { token: string; expiresAt: number } {
|
|
// 32 random bytes → 43-char URL-safe base64 (no padding)
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const now = Date.now();
|
|
const expiresAt = now + TTL_MS;
|
|
sessions.set(token, { createdAt: now, expiresAt });
|
|
pruneExpired(now);
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
/**
|
|
* Validate a token. Returns true only if the token exists AND is not expired.
|
|
* Expired tokens are lazily removed.
|
|
*/
|
|
export function validateSseSessionToken(token: string | null | undefined): boolean {
|
|
if (!token) return false;
|
|
const s = sessions.get(token);
|
|
if (!s) return false;
|
|
if (Date.now() > s.expiresAt) {
|
|
sessions.delete(token);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Parse the SSE session token from a Cookie header. */
|
|
export function extractSseCookie(req: Request): string | null {
|
|
const cookieHeader = req.headers.get('cookie');
|
|
if (!cookieHeader) return null;
|
|
for (const part of cookieHeader.split(';')) {
|
|
const [name, ...valueParts] = part.trim().split('=');
|
|
if (name === SSE_COOKIE_NAME) {
|
|
return valueParts.join('=') || null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build the Set-Cookie header value for the SSE session cookie.
|
|
* - HttpOnly: not readable from JS (mitigates XSS token exfiltration)
|
|
* - SameSite=Strict: not sent on cross-site requests (mitigates CSRF)
|
|
* - Path=/: scope to the whole origin so SSE endpoints can read it
|
|
* - Max-Age matches the TTL
|
|
*
|
|
* Secure is intentionally omitted: the daemon binds to 127.0.0.1 over
|
|
* plain HTTP, and setting Secure would prevent the browser from ever
|
|
* sending the cookie back. If gstack ever ships over HTTPS, add Secure.
|
|
*/
|
|
export function buildSseSetCookie(token: string): string {
|
|
const maxAge = Math.floor(TTL_MS / 1000);
|
|
return `${SSE_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
|
}
|
|
|
|
/** Build a Set-Cookie header that clears the SSE session cookie. */
|
|
export function buildSseClearCookie(): string {
|
|
return `${SSE_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
}
|
|
|
|
function pruneExpired(now: number): void {
|
|
// Opportunistic cleanup: at most 10 per mint call so we don't stall
|
|
// on a massive registry. O(1) amortized.
|
|
let checked = 0;
|
|
for (const [token, session] of sessions) {
|
|
if (checked++ >= 10) break;
|
|
if (session.expiresAt <= now) sessions.delete(token);
|
|
}
|
|
}
|
|
|
|
// Test-only reset.
|
|
export function __resetSseSessions(): void {
|
|
sessions.clear();
|
|
}
|