From dc356733ff12cfc9aa261f6fdc4e49f78fe57990 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 25 Apr 2026 12:33:28 -0700 Subject: [PATCH] feat(server): add pty-session-cookie module for the Terminal tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `sse-session-cookie.ts` exactly. Mints short-lived 30-min HttpOnly cookies for authenticating the Terminal-tab WebSocket upgrade against the terminal-agent. Same TTL, same opportunistic-pruning shape, same "scoped tokens never valid as root" invariant. Two registries instead of one because the cookie names are different (`gstack_sse` vs `gstack_pty`) and the token spaces must not overlap. No callers yet — wired up in the next commit. --- browse/src/pty-session-cookie.ts | 122 +++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 browse/src/pty-session-cookie.ts diff --git a/browse/src/pty-session-cookie.ts b/browse/src/pty-session-cookie.ts new file mode 100644 index 00000000..8871fe47 --- /dev/null +++ b/browse/src/pty-session-cookie.ts @@ -0,0 +1,122 @@ +/** + * Session cookie registry for the Terminal sidebar tab's PTY WebSocket. + * + * Why this exists: WebSocket clients in browsers cannot send Authorization + * headers on the upgrade request. The terminal-agent's /ws upgrade therefore + * authenticates via cookie. We never put the PTY token in /health (codex + * outside-voice finding #2: /health already leaks AUTH_TOKEN to any + * localhost caller in headed mode; reusing that path for shell access would + * widen an existing bug). Instead, the extension does an authenticated + * POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a + * short-lived cookie scoped to this terminal session and pushes it to the + * agent via loopback. The browser then carries the cookie automatically on + * the WS upgrade. + * + * Design mirrors `sse-session-cookie.ts` deliberately. Same TTL, same + * scoped-token-must-not-be-valid-as-root invariant, same opportunistic + * pruning. Two registries instead of one because the cookie names are + * different (`gstack_sse` vs `gstack_pty`) and the token spaces must not + * overlap — an SSE-read cookie must never grant PTY access, and vice versa. + */ +import * as crypto from 'crypto'; + +interface Session { + createdAt: number; + expiresAt: number; +} + +const TTL_MS = 30 * 60 * 1000; // 30 minutes — matches SSE cookie +const MAX_SESSIONS = 10_000; +const sessions = new Map(); + +export const PTY_COOKIE_NAME = 'gstack_pty'; + +/** Mint a fresh PTY session token. */ +export function mintPtySessionToken(): { token: string; expiresAt: number } { + 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. + * Lazily removes expired entries; opportunistically prunes a few more on + * every call so the registry stays bounded under reconnect pressure. + */ +export function validatePtySessionToken(token: string | null | undefined): boolean { + if (!token) return false; + const s = sessions.get(token); + if (!s) { + pruneExpired(Date.now()); + return false; + } + if (Date.now() > s.expiresAt) { + sessions.delete(token); + pruneExpired(Date.now()); + return false; + } + return true; +} + +/** + * Drop a session token (called on WS close so a leaked cookie can't be + * replayed against a new PTY). + */ +export function revokePtySessionToken(token: string | null | undefined): void { + if (!token) return; + sessions.delete(token); +} + +/** Parse the PTY session token from a Cookie header. */ +export function extractPtyCookie(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 === PTY_COOKIE_NAME) { + return valueParts.join('=') || null; + } + } + return null; +} + +/** + * Build the Set-Cookie header value for the PTY session cookie. + * - HttpOnly: not readable from JS (mitigates XSS exfiltration). + * - SameSite=Strict: not sent on cross-site requests (mitigates CSWSH). + * - Path=/: scope to whole origin so /ws and /pty-session both see it. + * - Max-Age matches the TTL. + * + * Secure is intentionally omitted: the daemon binds to 127.0.0.1 over plain + * HTTP; setting Secure would prevent the browser from ever sending it back. + */ +export function buildPtySetCookie(token: string): string { + const maxAge = Math.floor(TTL_MS / 1000); + return `${PTY_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`; +} + +/** Clear the PTY session cookie. */ +export function buildPtyClearCookie(): string { + return `${PTY_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`; +} + +function pruneExpired(now: number): void { + let checked = 0; + for (const [token, session] of sessions) { + if (checked++ >= 20) break; + if (session.expiresAt <= now) sessions.delete(token); + } + while (sessions.size > MAX_SESSIONS) { + const first = sessions.keys().next().value; + if (!first) break; + sessions.delete(first); + } +} + +// Test-only reset. +export function __resetPtySessions(): void { + sessions.clear(); +}