diff --git a/browse/src/server.ts b/browse/src/server.ts index c2bd07f8..3ab32b8d 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -42,6 +42,10 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory // fail posix_spawn on all executables including /bin/bash) import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling'; import { logTunnelDenial } from './tunnel-denial-log'; +import { + mintSseSessionToken, validateSseSessionToken, extractSseCookie, + buildSseSetCookie, SSE_COOKIE_NAME, +} from './sse-session-cookie'; import * as fs from 'fs'; import * as net from 'net'; import * as path from 'path'; @@ -1910,6 +1914,37 @@ async function start() { } } + // ─── SSE session cookie mint (auth required) ────────────────── + // + // Issues a short-lived view-only token in an HttpOnly SameSite=Strict + // cookie so EventSource calls can authenticate without putting the + // root token in a URL. The returned cookie is valid ONLY on the SSE + // endpoints (/activity/stream, /inspector/events); it is not a + // scoped token and cannot be used against /command. + // + // The extension calls this once at bootstrap with the root Bearer + // header, then opens EventSource with `withCredentials: true` which + // sends the cookie back automatically. + if (url.pathname === '/sse-session' && req.method === 'POST') { + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + const minted = mintSseSessionToken(); + return new Response(JSON.stringify({ + expiresAt: minted.expiresAt, + cookie: SSE_COOKIE_NAME, + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': buildSseSetCookie(minted.token), + }, + }); + } + // Refs endpoint — auth required, does NOT reset idle timer if (url.pathname === '/refs') { if (!validateAuth(req)) { @@ -1931,9 +1966,14 @@ async function start() { // Activity stream — SSE, auth required, does NOT reset idle timer if (url.pathname === '/activity/stream') { - // Inline auth: accept Bearer header OR ?token= query param (EventSource can't send headers) - const streamToken = url.searchParams.get('token'); - if (!validateAuth(req) && streamToken !== AUTH_TOKEN) { + // Auth: Bearer header OR view-only SSE session cookie (EventSource + // can't send Authorization headers, so the extension fetches a cookie + // via POST /sse-session first, then opens EventSource with + // withCredentials: true). The ?token= query param is NO LONGER + // accepted — URLs leak to logs/referer/history. See N1 in the + // v1.6.0.0 security wave plan. + const cookieToken = extractSseCookie(req); + if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, @@ -2563,8 +2603,10 @@ async function start() { // GET /inspector/events — SSE for inspector state changes (auth required) if (url.pathname === '/inspector/events' && req.method === 'GET') { - const streamToken = url.searchParams.get('token'); - if (!validateAuth(req) && streamToken !== AUTH_TOKEN) { + // Same auth model as /activity/stream: Bearer OR view-only cookie. + // ?token= query param dropped (see N1 in the v1.6.0.0 security plan). + const cookieToken = extractSseCookie(req); + if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); diff --git a/browse/src/sse-session-cookie.ts b/browse/src/sse-session-cookie.ts new file mode 100644 index 00000000..4e38f743 --- /dev/null +++ b/browse/src/sse-session-cookie.ts @@ -0,0 +1,110 @@ +/** + * 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(); + +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(); +} diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 1118aa18..52bb877b 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -72,13 +72,16 @@ describe('Server auth security', () => { expect(historyBlock).not.toContain("'*'"); }); - // Test 6: /activity/stream requires auth (inline Bearer or ?token= check) + // Test 6: /activity/stream requires auth via Bearer OR view-only session cookie + // (N1: ?token= query param was dropped in v1.6.0.0 — URLs leak to logs/referer) test('/activity/stream requires authentication with inline token check', () => { const streamBlock = sliceBetween(SERVER_SRC, "url.pathname === '/activity/stream'", "url.pathname === '/activity/history'"); expect(streamBlock).toContain('validateAuth'); - expect(streamBlock).toContain('AUTH_TOKEN'); + expect(streamBlock).toContain('validateSseSessionToken'); // Should not have wildcard CORS for the SSE stream expect(streamBlock).not.toContain("Access-Control-Allow-Origin': '*'"); + // ?token= query param must NOT be accepted anymore + expect(streamBlock).not.toContain("searchParams.get('token')"); }); // Test 7: /command accepts scoped tokens (not just root) diff --git a/browse/test/sse-session-cookie.test.ts b/browse/test/sse-session-cookie.test.ts new file mode 100644 index 00000000..0e27a916 --- /dev/null +++ b/browse/test/sse-session-cookie.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for the view-only SSE session cookie module. + * + * Verifies the registry lifecycle (mint/validate/expire), cookie flag + * invariants (HttpOnly, SameSite=Strict, no Secure), token entropy, and + * that scope is implicit (the registry has no cross-endpoint footprint + * that could be used to escalate the cookie to a scoped token). + */ + +import { describe, test, expect, beforeEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + mintSseSessionToken, validateSseSessionToken, extractSseCookie, + buildSseSetCookie, buildSseClearCookie, SSE_COOKIE_NAME, + __resetSseSessions, +} from '../src/sse-session-cookie'; + +const MODULE_SRC = fs.readFileSync( + path.join(import.meta.dir, '../src/sse-session-cookie.ts'), 'utf-8' +); + +beforeEach(() => __resetSseSessions()); + +describe('SSE session cookie: mint + validate', () => { + test('mint returns a token and an expiry', () => { + const { token, expiresAt } = mintSseSessionToken(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(20); + expect(expiresAt).toBeGreaterThan(Date.now()); + }); + + test('mint uses 32 random bytes (256-bit entropy)', () => { + // base64url of 32 bytes is 43 chars (no padding) + const { token } = mintSseSessionToken(); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + test('two mint calls produce different tokens', () => { + const a = mintSseSessionToken(); + const b = mintSseSessionToken(); + expect(a.token).not.toBe(b.token); + }); + + test('validate returns true for a just-minted token', () => { + const { token } = mintSseSessionToken(); + expect(validateSseSessionToken(token)).toBe(true); + }); + + test('validate returns false for an unknown token', () => { + expect(validateSseSessionToken('not-a-real-token')).toBe(false); + }); + + test('validate returns false for null/undefined/empty', () => { + expect(validateSseSessionToken(null)).toBe(false); + expect(validateSseSessionToken(undefined)).toBe(false); + expect(validateSseSessionToken('')).toBe(false); + }); +}); + +describe('SSE session cookie: TTL enforcement', () => { + test('TTL is 30 minutes', () => { + // Assert via source — the actual constant is module-private + expect(MODULE_SRC).toContain('const TTL_MS = 30 * 60 * 1000'); + }); + + test('a token with artificially rewound expiry is rejected', () => { + // Mint a token, then monkey-patch Date.now to simulate 31 minutes elapsed. + const { token, expiresAt } = mintSseSessionToken(); + const originalNow = Date.now; + try { + Date.now = () => expiresAt + 1; + expect(validateSseSessionToken(token)).toBe(false); + } finally { + Date.now = originalNow; + } + }); +}); + +describe('SSE session cookie: cookie flag invariants', () => { + test('Set-Cookie is HttpOnly', () => { + const { token } = mintSseSessionToken(); + expect(buildSseSetCookie(token)).toContain('HttpOnly'); + }); + + test('Set-Cookie is SameSite=Strict', () => { + const { token } = mintSseSessionToken(); + expect(buildSseSetCookie(token)).toContain('SameSite=Strict'); + }); + + test('Set-Cookie includes the token value', () => { + const { token } = mintSseSessionToken(); + expect(buildSseSetCookie(token)).toContain(`${SSE_COOKIE_NAME}=${token}`); + }); + + test('Set-Cookie Max-Age matches TTL', () => { + const { token } = mintSseSessionToken(); + // 30 minutes = 1800 seconds + expect(buildSseSetCookie(token)).toContain('Max-Age=1800'); + }); + + test('Set-Cookie does NOT set Secure (local HTTP daemon)', () => { + const { token } = mintSseSessionToken(); + // Adding Secure would block the browser from ever sending the cookie + // back to a 127.0.0.1 daemon over HTTP. If gstack ever moves to HTTPS, + // add Secure then. + expect(buildSseSetCookie(token)).not.toContain('Secure'); + }); + + test('Clear-Cookie has Max-Age=0', () => { + expect(buildSseClearCookie()).toContain('Max-Age=0'); + expect(buildSseClearCookie()).toContain('HttpOnly'); + }); +}); + +describe('SSE session cookie: extract from request', () => { + function mockReq(cookieHeader: string | null): Request { + const headers = new Headers(); + if (cookieHeader !== null) headers.set('cookie', cookieHeader); + return new Request('http://127.0.0.1/activity/stream', { headers }); + } + + test('extracts the token when cookie is present', () => { + const req = mockReq(`${SSE_COOKIE_NAME}=abc123`); + expect(extractSseCookie(req)).toBe('abc123'); + }); + + test('returns null when no cookie header', () => { + const req = mockReq(null); + expect(extractSseCookie(req)).toBeNull(); + }); + + test('returns null when cookie header has no gstack_sse', () => { + const req = mockReq('other=x; unrelated=y'); + expect(extractSseCookie(req)).toBeNull(); + }); + + test('extracts gstack_sse from a multi-cookie header', () => { + const req = mockReq(`other=x; ${SSE_COOKIE_NAME}=real-token; trailing=y`); + expect(extractSseCookie(req)).toBe('real-token'); + }); + + test('handles tokens with base64url padding-like chars', () => { + // real tokens contain A-Z, a-z, 0-9, _, - + const req = mockReq(`${SSE_COOKIE_NAME}=AbCd-_xyz`); + expect(extractSseCookie(req)).toBe('AbCd-_xyz'); + }); +}); + +describe('SSE session cookie: scope isolation (prior learning cookie-picker-auth-isolation)', () => { + test('the module exposes ONLY view-only functions, no scoped-token hooks', () => { + // This is a contract guard: if someone later makes SSE session tokens + // valid as scoped tokens (e.g., by exporting a helper that registers + // them in the main token registry), a leaked cookie could execute + // /command. The module must not import from token-registry. + expect(MODULE_SRC).not.toContain("from './token-registry'"); + expect(MODULE_SRC).not.toContain('createToken'); + expect(MODULE_SRC).not.toContain('initRegistry'); + }); +}); diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 63b869b7..6f449990 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1036,13 +1036,34 @@ function escapeHtml(str) { // ─── SSE Connection ───────────────────────────────────────────── -function connectSSE() { +// Fetch a view-only SSE session cookie before opening EventSource. +// EventSource can't send Authorization headers, and putting the root +// token in the URL (the old ?token= path) leaks it to logs, referer +// headers, and browser history. POST /sse-session issues an HttpOnly +// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true +// on EventSource makes the browser send it back. +async function ensureSseSessionCookie() { + if (!serverUrl || !serverToken) return false; + try { + const resp = await fetch(`${serverUrl}/sse-session`, { + method: 'POST', + credentials: 'include', + headers: { 'Authorization': `Bearer ${serverToken}` }, + }); + return resp.ok; + } catch (err) { + console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message); + return false; + } +} + +async function connectSSE() { if (!serverUrl) return; if (eventSource) { eventSource.close(); eventSource = null; } - const tokenParam = serverToken ? `&token=${serverToken}` : ''; - const url = `${serverUrl}/activity/stream?after=${lastId}${tokenParam}`; - eventSource = new EventSource(url); + await ensureSseSessionCookie(); + const url = `${serverUrl}/activity/stream?after=${lastId}`; + eventSource = new EventSource(url, { withCredentials: true }); eventSource.addEventListener('activity', (e) => { try { addEntry(JSON.parse(e.data)); } catch (err) { @@ -1595,15 +1616,17 @@ document.querySelectorAll('.inspector-section-toggle').forEach(toggle => { // ─── Inspector SSE ────────────────────────────────────────────── -function connectInspectorSSE() { +async function connectInspectorSSE() { if (!serverUrl || !serverToken) return; if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; } - const tokenParam = serverToken ? `&token=${serverToken}` : ''; - const url = `${serverUrl}/inspector/events?_=${Date.now()}${tokenParam}`; + // Same session-cookie pattern as connectSSE. ?token= is gone (see N1 + // in the v1.6.0.0 security wave plan). + await ensureSseSessionCookie(); + const url = `${serverUrl}/inspector/events?_=${Date.now()}`; try { - inspectorSSE = new EventSource(url); + inspectorSSE = new EventSource(url, { withCredentials: true }); inspectorSSE.addEventListener('inspectResult', (e) => { try {