/** * 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, listProfiles, listDomains, importCookies, importCookiesViaCdp, hasV20Cookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser'; import { getCookiePickerHTML } from './cookie-picker-ui'; // ─── 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; } /** Return true while the picker still has a live code or session. */ export function hasActivePicker(): boolean { const now = Date.now(); for (const [code, expiry] of pendingCodes) { if (expiry > now) return true; pendingCodes.delete(code); } for (const [session, expiry] of validSessions) { if (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 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. 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', }, }); } 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 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 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' }, }); } // 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 }); } const profiles = listProfiles(browserName); return jsonResponse({ profiles }, { 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 = 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 } = 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 }); } // Decrypt cookies from the browser DB const selectedProfile = profile || 'Default'; let result = await importCookies(browser, domains, selectedProfile); // If all cookies failed and v20 encryption is detected, try CDP extraction if (result.cookies.length === 0 && result.failed > 0 && hasV20Cookies(browser, selectedProfile)) { console.log(`[cookie-picker] v20 App-Bound Encryption detected, trying CDP extraction...`); try { result = await importCookiesViaCdp(browser, domains, selectedProfile); } catch (cdpErr: any) { console.log(`[cookie-picker] CDP fallback failed: ${cdpErr.message}`); return jsonResponse({ imported: 0, failed: result.failed, domainCounts: {}, message: `Cookies use App-Bound Encryption (v20). Close ${browser}, retry, or use /connect-chrome to browse with your real browser directly.`, code: 'v20_encryption', }, { port }); } } if (result.cookies.length === 0) { return jsonResponse({ imported: 0, failed: result.failed, domainCounts: {}, message: result.failed > 0 ? `All ${result.failed} cookies failed to decrypt` : 'No cookies found for the specified domains', }, { port }); } // Add to Playwright context const page = bm.getActiveSession().getPage(); await page.context().addCookies(result.cookies); // Track what was imported for (const domain of Object.keys(result.domainCounts)) { importedDomains.add(domain); importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]); } console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`); return jsonResponse({ imported: result.count, failed: result.failed, domainCounts: result.domainCounts, }, { 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] Error: ${err.message}`); return errorResponse(err.message || 'Internal error', 'internal_error', { port, status: 500 }); } }