diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 6b8499a0..a6ee9da1 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -4,20 +4,59 @@ * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts * (decryption) and cookie-picker-ui.ts (HTML generation). * - * Routes (no auth — localhost-only, accepted risk): - * GET /cookie-picker → serves the picker HTML page - * GET /cookie-picker/browsers → list installed browsers - * GET /cookie-picker/domains → list domains + counts for a browser - * POST /cookie-picker/import → decrypt + import cookies to Playwright - * POST /cookie-picker/remove → clear cookies for domains - * GET /cookie-picker/imported → currently imported domains + counts + * Auth model (post-CVE fix): + * GET /cookie-picker → requires one-time code (?code=) or session cookie + * GET /cookie-picker/browsers → requires Bearer token or session cookie + * GET /cookie-picker/domains → requires Bearer token or session cookie + * POST /cookie-picker/import → requires Bearer token or session cookie + * POST /cookie-picker/remove → requires Bearer token or session cookie + * GET /cookie-picker/imported → requires Bearer token or session cookie + * + * The session cookie (gstack_picker) is isolated from the scoped token system. + * It is NOT valid for /command. This prevents session cookie extraction from + * re-enabling the auth token leak vulnerability. */ +import * as crypto from 'crypto'; import type { BrowserManager } from './browser-manager'; import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; -// ─── State ────────────────────────────────────────────────────── +// ─── Auth State ───────────────────────────────────────────────── +// One-time codes for the cookie picker UI (code → expiry timestamp). +// Codes are generated by generatePickerCode() and consumed on first use. +const pendingCodes = new Map(); +const CODE_TTL_MS = 30_000; // 30 seconds + +// Session cookies for authenticated picker access (session → expiry timestamp). +// Sessions are created after a valid code exchange and last 1 hour. +const validSessions = new Map(); +const SESSION_TTL_MS = 3_600_000; // 1 hour + +/** Generate a one-time code for opening the cookie picker UI. */ +export function generatePickerCode(): string { + const code = crypto.randomUUID(); + pendingCodes.set(code, Date.now() + CODE_TTL_MS); + return code; +} + +/** Extract session ID from the gstack_picker cookie. */ +function getSessionFromCookie(req: Request): string | null { + const cookie = req.headers.get('cookie'); + if (!cookie) return null; + const match = cookie.match(/gstack_picker=([^;]+)/); + return match ? match[1] : null; +} + +/** Check if a session cookie value is valid and not expired. */ +function isValidSession(session: string): boolean { + const expiry = validSessions.get(session); + if (!expiry) return false; + if (Date.now() > expiry) { validSessions.delete(session); return false; } + return true; +} + +// ─── Domain State ─────────────────────────────────────────────── // Tracks which domains were imported via the picker. // /imported only returns cookies for domains in this Set. // /remove clears from this Set. @@ -71,19 +110,56 @@ export async function handleCookiePickerRoute( } try { - // GET /cookie-picker — serve the picker UI + // GET /cookie-picker — serve the picker UI (requires code or session cookie) if (pathname === '/cookie-picker' && req.method === 'GET') { - const html = getCookiePickerHTML(port, authToken); - return new Response(html, { - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, + const code = url.searchParams.get('code'); + + // Code exchange: validate one-time code, set session cookie, redirect + if (code) { + const expiry = pendingCodes.get(code); + if (!expiry || Date.now() > expiry) { + pendingCodes.delete(code); + return new Response('Invalid or expired code. Re-run cookie-import-browser.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, + }); + } + pendingCodes.delete(code); // one-time use + const session = crypto.randomUUID(); + validSessions.set(session, Date.now() + SESSION_TTL_MS); + return new Response(null, { + status: 302, + headers: { + 'Location': '/cookie-picker', + 'Set-Cookie': `gstack_picker=${session}; HttpOnly; SameSite=Strict; Path=/cookie-picker; Max-Age=3600`, + 'Cache-Control': 'no-store', + }, + }); + } + + // Session cookie: serve HTML (no auth token inlined) + const session = getSessionFromCookie(req); + if (session && isValidSession(session)) { + const html = getCookiePickerHTML(port); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // No code, no session: reject + return new Response('Access denied. Open the cookie picker from gstack.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, }); } - // ─── Auth gate: all data/action routes below require Bearer token ─── - // Auth is mandatory — if authToken is undefined, reject all requests + // ─── Auth gate: all data/action routes below require Bearer token or session cookie ─── const authHeader = req.headers.get('authorization'); - if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) { + const sessionId = getSessionFromCookie(req); + const hasBearer = !!authToken && !!authHeader && authHeader === `Bearer ${authToken}`; + const hasSession = sessionId !== null && isValidSession(sessionId); + if (!hasBearer && !hasSession) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index 03089b08..bf151adb 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -7,7 +7,7 @@ * No cookie values exposed anywhere. */ -export function getCookiePickerHTML(serverPort: number, authToken?: string): string { +export function getCookiePickerHTML(serverPort: number): string { const baseUrl = `http://127.0.0.1:${serverPort}`; return ` @@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str