cookie-picker-routes.ts
343 lines · browse/src/cookie-picker-routes.ts at 3f79d81
1/**
2 * Cookie picker route handler — HTTP + Playwright glue
3 *
4 * Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
5 * (decryption) and cookie-picker-ui.ts (HTML generation).
6 *
7 * Auth model (post-CVE fix):
8 * GET /cookie-picker → requires one-time code (?code=) or session cookie
9 * GET /cookie-picker/browsers → requires Bearer token or session cookie
10 * GET /cookie-picker/domains → requires Bearer token or session cookie
11 * POST /cookie-picker/import → requires Bearer token or session cookie
12 * POST /cookie-picker/remove → requires Bearer token or session cookie
13 * GET /cookie-picker/imported → requires Bearer token or session cookie
14 *
15 * The session cookie (gstack_picker) is isolated from the scoped token system.
16 * It is NOT valid for /command. This prevents session cookie extraction from
17 * re-enabling the auth token leak vulnerability.
18 */
20import * as crypto from 'crypto';
21import type { BrowserManager } from './browser-manager';
22import { findInstalledBrowsers, listDomains, withCookieReadRetry, CookieImportError } from './cookie-import-browser';
23import { getCookiePickerHTML } from './cookie-picker-ui';
24import { getCookieProfiles, runCookieImport, type CookieImportTarget } from './cookie-import-operation';
25import { validateCookieStorageSupport } from './cookie-auth-verification';
27// ─── Auth State ─────────────────────────────────────────────────
28// One-time codes for the cookie picker UI (code → expiry timestamp).
29// Codes are generated by generatePickerCode() and consumed on first use.
30interface PickerContext {
31 target?: CookieImportTarget;
32 browser?: string;
33 profile?: string;
34 clearStorage?: boolean;
35 verifyAuth?: boolean;
36}
38const pendingCodes = new Map<string, PickerContext & { expiry: number }>();
39const CODE_TTL_MS = 5 * 60_000;
41// Session cookies for authenticated picker access (session → expiry timestamp).
42// Sessions are created after a valid code exchange and last 1 hour.
43const validSessions = new Map<string, PickerContext & { expiry: number; pickerInstance: string }>();
44const SESSION_TTL_MS = 3_600_000; // 1 hour
46/** Generate a one-time code for opening the cookie picker UI. */
47export function generatePickerCode(context: PickerContext = {}): string {
48 const code = crypto.randomUUID();
49 pendingCodes.set(code, { ...context, expiry: Date.now() + CODE_TTL_MS });
50 return code;
51}
53/** Return true while the picker still has a live code or session. */
54export function hasActivePicker(): boolean {
55 const now = Date.now();
57 for (const [code, context] of pendingCodes) {
58 if (context.expiry > now) return true;
59 pendingCodes.delete(code);
60 }
62 for (const [session, context] of validSessions) {
63 if (context.expiry > now) return true;
64 validSessions.delete(session);
65 }
67 return false;
68}
70/** Extract session ID from the gstack_picker cookie. */
71function getSessionFromCookie(req: Request): string | null {
72 const cookie = req.headers.get('cookie');
73 if (!cookie) return null;
74 const match = cookie.match(/gstack_picker=([^;]+)/);
75 return match ? match[1] : null;
76}
78/** Check if a session cookie value is valid and not expired. */
79function isValidSession(session: string): boolean {
80 const context = validSessions.get(session);
81 if (!context) return false;
82 if (Date.now() >= context.expiry) { validSessions.delete(session); return false; }
83 return true;
84}
86// ─── Domain State ───────────────────────────────────────────────
87// Tracks which domains were imported via the picker.
88// /imported only returns cookies for domains in this Set.
89// /remove clears from this Set.
90const importedDomains = new Set<string>();
91const importedCounts = new Map<string, number>();
93// ─── JSON Helpers ───────────────────────────────────────────────
95function corsOrigin(port: number): string {
96 return `http://127.0.0.1:${port}`;
97}
99function jsonResponse(data: any, opts: { port: number; status?: number }): Response {
100 return new Response(JSON.stringify(data), {
101 status: opts.status ?? 200,
102 headers: {
103 'Content-Type': 'application/json',
104 'Access-Control-Allow-Origin': corsOrigin(opts.port),
105 },
106 });
107}
109function errorResponse(message: string, code: string, opts: { port: number; status?: number; action?: string }): Response {
110 return jsonResponse(
111 { error: message, code, ...(opts.action ? { action: opts.action } : {}) },
112 { port: opts.port, status: opts.status ?? 400 },
113 );
114}
116// ─── Route Handler ──────────────────────────────────────────────
118export async function handleCookiePickerRoute(
119 url: URL,
120 req: Request,
121 bm: BrowserManager,
122 authToken?: string,
123): Promise<Response> {
124 const pathname = url.pathname;
125 const port = parseInt(url.port, 10) || 9400;
127 // CORS preflight
128 if (req.method === 'OPTIONS') {
129 return new Response(null, {
130 status: 204,
131 headers: {
132 'Access-Control-Allow-Origin': corsOrigin(port),
133 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
134 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Gstack-Picker-Instance',
135 },
136 });
137 }
139 try {
140 // GET /cookie-picker — serve the picker UI (requires code or session cookie)
141 if (pathname === '/cookie-picker' && req.method === 'GET') {
142 const code = url.searchParams.get('code');
144 // Code exchange: validate one-time code, set session cookie, redirect
145 if (code) {
146 const context = pendingCodes.get(code);
147 if (!context || Date.now() >= context.expiry) {
148 pendingCodes.delete(code);
149 return new Response('Invalid or expired code. Re-run cookie-import-browser.', {
150 status: 403,
151 headers: { 'Content-Type': 'text/plain' },
152 });
153 }
154 pendingCodes.delete(code); // one-time use
155 const session = crypto.randomUUID();
156 validSessions.set(session, { ...context, expiry: Date.now() + SESSION_TTL_MS, pickerInstance: crypto.randomUUID() });
157 return new Response(null, {
158 status: 302,
159 headers: {
160 'Location': '/cookie-picker',
161 'Set-Cookie': `gstack_picker=${session}; HttpOnly; SameSite=Strict; Path=/cookie-picker; Max-Age=3600`,
162 'Cache-Control': 'no-store',
163 },
164 });
165 }
167 // Session cookie: serve HTML (no auth token inlined)
168 const session = getSessionFromCookie(req);
169 if (session && isValidSession(session)) {
170 const context = validSessions.get(session)!;
171 let targetOrigin: string | undefined;
172 let storageResetAvailable = false;
173 try {
174 const url = new URL(context.target?.url ?? '');
175 if (['http:', 'https:'].includes(url.protocol)) targetOrigin = url.origin;
176 } catch {}
177 if (context.target) {
178 try { validateCookieStorageSupport(context.target.page); storageResetAvailable = true; } catch {}
179 }
180 const html = getCookiePickerHTML(port, {
181 pickerInstance: context.pickerInstance,
182 browser: context.browser,
183 profile: context.profile,
184 clearStorage: context.clearStorage,
185 verifyAuth: context.verifyAuth,
186 targetOrigin,
187 storageResetAvailable,
188 verificationAvailable: !!process.env.GSTACK_COOKIE_AUTH_SELECTOR?.trim() && !!process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY?.trim(),
189 });
190 return new Response(html, {
191 status: 200,
192 headers: { 'Content-Type': 'text/html; charset=utf-8' },
193 });
194 }
196 // No code, no session: reject
197 return new Response('Access denied. Open the cookie picker from gstack.', {
198 status: 403,
199 headers: { 'Content-Type': 'text/plain' },
200 });
201 }
203 // ─── Auth gate: all data/action routes below require Bearer token or session cookie ───
204 const authHeader = req.headers.get('authorization');
205 const sessionId = getSessionFromCookie(req);
206 const hasBearer = !!authToken && !!authHeader && authHeader === `Bearer ${authToken}`;
207 const hasSession = sessionId !== null && isValidSession(sessionId);
208 if (!hasBearer && !hasSession) {
209 return new Response(JSON.stringify({ error: 'Unauthorized' }), {
210 status: 401,
211 headers: { 'Content-Type': 'application/json' },
212 });
213 }
214 if (req.method === 'POST' && !hasBearer && req.headers.get('origin') !== url.origin) {
215 return errorResponse('Cookie picker mutations require a same-origin request.', 'invalid_origin', { port, status: 403 });
216 }
217 const pickerContext = !hasBearer && hasSession ? validSessions.get(sessionId!)! : undefined;
218 if (pickerContext && req.headers.get('X-Gstack-Picker-Instance') !== pickerContext.pickerInstance) {
219 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 });
220 }
222 // GET /cookie-picker/browsers — list installed browsers
223 if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
224 const browsers = findInstalledBrowsers();
225 return jsonResponse({
226 browsers: browsers.map(b => ({
227 name: b.name,
228 aliases: b.aliases,
229 })),
230 }, { port });
231 }
233 // GET /cookie-picker/profiles?browser=<name> — list profiles for a browser
234 if (pathname === '/cookie-picker/profiles' && req.method === 'GET') {
235 const browserName = url.searchParams.get('browser');
236 if (!browserName) {
237 return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
238 }
239 let hostname: string | undefined;
240 try { hostname = new URL(pickerContext?.target?.url ?? '').hostname || undefined; } catch {}
241 return jsonResponse(await getCookieProfiles(browserName, [], hostname), { port });
242 }
244 // GET /cookie-picker/domains?browser=<name>&profile=<profile> — list domains + counts
245 if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
246 const browserName = url.searchParams.get('browser');
247 if (!browserName) {
248 return errorResponse("Missing 'browser' parameter", 'missing_param', { port });
249 }
250 const profile = url.searchParams.get('profile') || 'Default';
251 const result = await withCookieReadRetry(() => listDomains(browserName, profile));
252 return jsonResponse({
253 browser: result.browser,
254 domains: result.domains,
255 }, { port });
256 }
258 // POST /cookie-picker/import — decrypt + import to Playwright session
259 if (pathname === '/cookie-picker/import' && req.method === 'POST') {
260 let body: any;
261 try {
262 body = await req.json();
263 } catch {
264 return errorResponse('Invalid JSON body', 'bad_request', { port });
265 }
267 const { browser, domains, profile, clearStorage, verifyAuth } = body ?? {};
268 if (!browser) return errorResponse("Missing 'browser' field", 'missing_param', { port });
269 if (!domains || !Array.isArray(domains) || domains.length === 0) {
270 return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
271 }
273 const page = pickerContext?.target?.page ?? bm.getActiveSession().getPage();
274 const target = pickerContext?.target ?? { page, url: page.url() };
275 const result = await runCookieImport({
276 browser, domains, profile,
277 clearStorage: clearStorage ?? pickerContext?.clearStorage ?? false,
278 verifyAuth: verifyAuth ?? pickerContext?.verifyAuth ?? false,
279 }, target, domains => bm.trackCookieImportDomains(domains), {
280 identitySelector: process.env.GSTACK_COOKIE_AUTH_SELECTOR,
281 expectedIdentity: process.env.GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY,
282 });
283 for (const domain of Object.keys(result.domainCounts)) {
284 importedDomains.add(domain);
285 importedCounts.set(domain, result.domainCounts[domain]);
286 }
287 return jsonResponse(result, { port });
288 }
290 // POST /cookie-picker/remove — clear cookies for domains
291 if (pathname === '/cookie-picker/remove' && req.method === 'POST') {
292 let body: any;
293 try {
294 body = await req.json();
295 } catch {
296 return errorResponse('Invalid JSON body', 'bad_request', { port });
297 }
299 const { domains } = body;
300 if (!domains || !Array.isArray(domains) || domains.length === 0) {
301 return errorResponse("Missing or empty 'domains' array", 'missing_param', { port });
302 }
304 const page = bm.getActiveSession().getPage();
305 const context = page.context();
306 for (const domain of domains) {
307 await context.clearCookies({ domain });
308 importedDomains.delete(domain);
309 importedCounts.delete(domain);
310 }
312 console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
314 return jsonResponse({
315 removed: domains.length,
316 domains,
317 }, { port });
318 }
320 // GET /cookie-picker/imported — currently imported domains + counts
321 if (pathname === '/cookie-picker/imported' && req.method === 'GET') {
322 const entries: Array<{ domain: string; count: number }> = [];
323 for (const domain of importedDomains) {
324 entries.push({ domain, count: importedCounts.get(domain) || 0 });
325 }
326 entries.sort((a, b) => b.count - a.count);
328 return jsonResponse({
329 domains: entries,
330 totalDomains: entries.length,
331 totalCookies: entries.reduce((sum, e) => sum + e.count, 0),
332 }, { port });
333 }
335 return new Response('Not found', { status: 404 });
336 } catch (err: any) {
337 if (err instanceof CookieImportError) {
338 return errorResponse(err.message, err.code, { port, status: 400, action: err.action });
339 }
340 console.error('[cookie-picker] Operation failed');
341 return errorResponse('Cookie picker operation failed. Retry or reopen the picker.', 'internal_error', { port, status: 500 });
342 }
343}