/** * Cookie picker route handler — HTTP + Playwright glue * * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts * (decryption) and cookie-picker-ui.ts (HTML generation). * * 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, listDomains, withCookieReadRetry, CookieImportError } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; import { getCookieProfiles, runCookieImport, type CookieImportTarget } from './cookie-import-operation'; import { validateCookieStorageSupport } from './cookie-auth-verification'; // ─── Auth State ───────────────────────────────────────────────── // One-time codes for the cookie picker UI (code → expiry timestamp). // Codes are generated by generatePickerCode() and consumed on first use. interface PickerContext { target?: CookieImportTarget; browser?: string; profile?: string; clearStorage?: boolean; verifyAuth?: boolean; } const pendingCodes = new Map(); const CODE_TTL_MS = 5 * 60_000; // 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(context: PickerContext = {}): string { const code = crypto.randomUUID(); pendingCodes.set(code, { ...context, expiry: Date.now() + CODE_TTL_MS }); return code; } /** Return true while the picker still has a live code or session. */ export function hasActivePicker(): boolean { const now = Date.now(); for (const [code, context] of pendingCodes) { if (context.expiry > now) return true; pendingCodes.delete(code); } for (const [session, context] of validSessions) { if (context.expiry > now) return true; validSessions.delete(session); } return false; } /** 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 context = validSessions.get(session); if (!context) return false; if (Date.now() >= context.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. const importedDomains = new Set(); const importedCounts = new Map(); // ─── JSON Helpers ─────────────────────────────────────────────── function corsOrigin(port: number): string { return `http://127.0.0.1:${port}`; } function jsonResponse(data: any, opts: { port: number; status?: number }): Response { return new Response(JSON.stringify(data), { status: opts.status ?? 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': corsOrigin(opts.port), }, }); } function errorResponse(message: string, code: string, opts: { port: number; status?: number; action?: string }): Response { return jsonResponse( { error: message, code, ...(opts.action ? { action: opts.action } : {}) }, { port: opts.port, status: opts.status ?? 400 }, ); } // ─── Route Handler ────────────────────────────────────────────── export async function handleCookiePickerRoute( url: URL, req: Request, bm: BrowserManager, authToken?: string, ): Promise { const pathname = url.pathname; const port = parseInt(url.port, 10) || 9400; // CORS preflight if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': corsOrigin(port), 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Gstack-Picker-Instance', }, }); } try { // GET /cookie-picker — serve the picker UI (requires code or session cookie) if (pathname === '/cookie-picker' && req.method === 'GET') { const code = url.searchParams.get('code'); // Code exchange: validate one-time code, set session cookie, redirect if (code) { const context = pendingCodes.get(code); if (!context || Date.now() >= context.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, { ...context, expiry: Date.now() + SESSION_TTL_MS, pickerInstance: crypto.randomUUID() }); 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 context = validSessions.get(session)!; let targetOrigin: string | undefined; let storageResetAvailable = false; try { const url = new URL(context.target?.url ?? ''); if (['http:', 'https:'].includes(url.protocol)) targetOrigin = url.origin; } catch {} if (context.target) { try { validateCookieStorageSupport(context.target.page); storageResetAvailable = true; } catch {} } const html = getCookiePickerHTML(port, { pickerInstance: context.pickerInstance, browser: context.browser, profile: context.profile, clearStorage: context.clearStorage, verifyAuth: context.verifyAuth, targetOrigin, storageResetAvailable, verificationAvailable: !!process.env.GSTACK_COOKIE_AUTH_SELECTOR?.trim() && !!process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY?.trim(), }); 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 or session cookie ─── const authHeader = req.headers.get('authorization'); 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' }, }); } if (req.method === 'POST' && !hasBearer && req.headers.get('origin') !== url.origin) { return errorResponse('Cookie picker mutations require a same-origin request.', 'invalid_origin', { port, status: 403 }); } const pickerContext = !hasBearer && hasSession ? validSessions.get(sessionId!)! : undefined; if (pickerContext && req.headers.get('X-Gstack-Picker-Instance') !== pickerContext.pickerInstance) { return errorResponse('This picker no longer matches the active picker session. Reopen the picker from the intended page before continuing.', 'picker_changed', { port, status: 403 }); } // GET /cookie-picker/browsers — list installed browsers if (pathname === '/cookie-picker/browsers' && req.method === 'GET') { const browsers = findInstalledBrowsers(); return jsonResponse({ browsers: browsers.map(b => ({ name: b.name, aliases: b.aliases, })), }, { port }); } // GET /cookie-picker/profiles?browser= — list profiles for a browser if (pathname === '/cookie-picker/profiles' && req.method === 'GET') { const browserName = url.searchParams.get('browser'); if (!browserName) { return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } let hostname: string | undefined; try { hostname = new URL(pickerContext?.target?.url ?? '').hostname || undefined; } catch {} return jsonResponse(await getCookieProfiles(browserName, [], hostname), { port }); } // GET /cookie-picker/domains?browser=&profile= — list domains + counts if (pathname === '/cookie-picker/domains' && req.method === 'GET') { const browserName = url.searchParams.get('browser'); if (!browserName) { return errorResponse("Missing 'browser' parameter", 'missing_param', { port }); } const profile = url.searchParams.get('profile') || 'Default'; const result = await withCookieReadRetry(() => listDomains(browserName, profile)); return jsonResponse({ browser: result.browser, domains: result.domains, }, { port }); } // POST /cookie-picker/import — decrypt + import to Playwright session if (pathname === '/cookie-picker/import' && req.method === 'POST') { let body: any; try { body = await req.json(); } catch { return errorResponse('Invalid JSON body', 'bad_request', { port }); } const { browser, domains, profile, clearStorage, verifyAuth } = body ?? {}; if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port }); if (!domains || !Array.isArray(domains) || domains.length === 0) { return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); } const page = pickerContext?.target?.page ?? bm.getActiveSession().getPage(); const target = pickerContext?.target ?? { page, url: page.url() }; const result = await runCookieImport({ browser, domains, profile, clearStorage: clearStorage ?? pickerContext?.clearStorage ?? false, verifyAuth: verifyAuth ?? pickerContext?.verifyAuth ?? false, }, target, domains => bm.trackCookieImportDomains(domains), { identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR, expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY, }); for (const domain of Object.keys(result.domainCounts)) { importedDomains.add(domain); importedCounts.set(domain, result.domainCounts[domain]); } return jsonResponse(result, { port }); } // POST /cookie-picker/remove — clear cookies for domains if (pathname === '/cookie-picker/remove' && req.method === 'POST') { let body: any; try { body = await req.json(); } catch { return errorResponse('Invalid JSON body', 'bad_request', { port }); } const { domains } = body; if (!domains || !Array.isArray(domains) || domains.length === 0) { return errorResponse("Missing or empty 'domains' array", 'missing_param', { port }); } const page = bm.getActiveSession().getPage(); const context = page.context(); for (const domain of domains) { await context.clearCookies({ domain }); importedDomains.delete(domain); importedCounts.delete(domain); } console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`); return jsonResponse({ removed: domains.length, domains, }, { port }); } // GET /cookie-picker/imported — currently imported domains + counts if (pathname === '/cookie-picker/imported' && req.method === 'GET') { const entries: Array<{ domain: string; count: number }> = []; for (const domain of importedDomains) { entries.push({ domain, count: importedCounts.get(domain) || 0 }); } entries.sort((a, b) => b.count - a.count); return jsonResponse({ domains: entries, totalDomains: entries.length, totalCookies: entries.reduce((sum, e) => sum + e.count, 0), }, { port }); } return new Response('Not found', { status: 404 }); } catch (err: any) { if (err instanceof CookieImportError) { return errorResponse(err.message, err.code, { port, status: 400, action: err.action }); } console.error('[cookie-picker] Operation failed'); return errorResponse('Cookie picker operation failed. Retry or reopen the picker.', 'internal_error', { port, status: 500 }); } }