diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 66328432a..9029c2fcd 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -40,6 +40,8 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import type { Writable as NodeWritable, Readable as NodeReadable } from 'node:stream'; +import { spawn as nodeSpawn } from 'node:child_process'; import { TEMP_DIR } from './platform'; // ─── Types ────────────────────────────────────────────────────── @@ -787,14 +789,53 @@ function isBrowserRunning(browserName: string): Promise { }); } +export function buildCdpSpawnArgs( + exePath: string, + userDataDir: string, + profile: string, +): string[] { + // First element is the exe path; the rest are argv[1..] for Chrome. + // Keeping the exe in the array lets tests assert on a single source of truth. + return [ + exePath, + '--remote-debugging-pipe', + `--user-data-dir=${userDataDir}`, + `--profile-directory=${profile}`, + '--headless=new', + '--no-first-run', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--no-default-browser-check', + ]; +} + /** - * Extract cookies via Chrome DevTools Protocol. Launches Chrome headless with - * remote debugging on the user's real profile directory. Requires Chrome to be - * closed first (profile lock). + * Extract cookies via Chrome DevTools Protocol over a stdio pipe. Launches + * Chrome headless with --remote-debugging-pipe on the user's real profile. + * Requires Chrome to be closed first (profile lock). * * v20 App-Bound Encryption binds decryption keys to the original user-data-dir * path, so a temp copy of the profile won't work — Chrome silently discards * cookies it can't decrypt. We must use the real profile. + * + * Transport: the CDP transport is Chrome's stdio pipes (parent fd 3 = write, + * parent fd 4 = read), not a TCP socket. There is no debug port to scan and + * no /json/list HTTP endpoint. A same-user process on the machine cannot + * observe or inject CDP traffic because pipe inheritance only reaches the + * child we spawned. This closes the v20 cookie exfiltration window tracked + * in issue #1136 from the v1.6.0.0 security wave. + * + * We use node:child_process.spawn instead of Bun.spawn for this subprocess + * because Bun.spawn returns raw numeric fds (not stream objects) at stdio + * indices >= 3, and Writable.toWeb / Readable.toWeb hang in Bun on the + * net.Socket endpoints. node:child_process returns net.Socket objects that + * work directly with CdpPipeTransport. + * + * Debugging note: if this path starts failing after a Chrome update, check + * the Chrome version logged below — Chrome's ABE key format (v20) or CDP + * pipe framing can change between major versions. */ export async function importCookiesViaCdp( browserName: string, @@ -823,99 +864,53 @@ export async function importCookiesViaCdp( ); } - // Must use the real user data dir — v20 ABE keys are path-bound + // Must use the real user data dir — v20 ABE keys are path-bound. const dataDir = getDataDirForPlatform(browser, 'win32'); if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed'); const userDataDir = path.join(getBaseDir('win32'), dataDir); - // Launch Chrome headless with remote debugging on the real profile. - // - // Security posture of the debug port: - // - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The - // port is NOT exposed to the network. Baseline threat: a local - // process running as the same user can connect. - // - Port is randomized in [9222, 9321] to avoid collisions with other - // Chrome-based tools. Not cryptographic — security relies on - // same-user-access baseline, not port secrecy. - // - Chrome is always killed in the finally block below (even on crash). - // - // KNOWN NON-GOAL (tracked as a separate hardening task for the next - // security wave): - // On Windows 10.15+ with App-Bound Encryption (v20) enabled, a - // same-user process that opens the cookie DB directly cannot decrypt - // v20 values — the DPAPI context is bound to the browser process. - // The CDP port bypasses that: `Network.getAllCookies` runs inside the - // browser, so any same-user process that connects to the debug port - // before we kill Chrome could exfiltrate decrypted v20 cookies. - // Fix direction: switch to `--remote-debugging-pipe` so the CDP - // transport is a parent/child stdio pipe, not TCP. Requires - // restructuring the extractCookiesViaCdp WebSocket client; deferred - // to a follow-up because the transport swap is non-trivial and the - // baseline threat is still "attacker already has same-user access." - // - // Debugging note: if this path starts failing after a Chrome update, - // check the Chrome version logged below — Chrome's ABE key format (v20) - // or /json/list shape can change between major versions. - const debugPort = 9222 + Math.floor(Math.random() * 100); - const chromeProc = Bun.spawn([ - exePath, - `--remote-debugging-port=${debugPort}`, - `--user-data-dir=${userDataDir}`, - `--profile-directory=${profile}`, - '--headless=new', - '--no-first-run', - '--disable-background-networking', - '--disable-default-apps', - '--disable-extensions', - '--disable-sync', - '--no-default-browser-check', - ], { stdout: 'pipe', stderr: 'pipe' }); + // Spawn Chrome with CDP over stdio pipes: + // stdio[0] stdin — ignored (Chrome expects nothing on stdin) + // stdio[1] stdout — piped + // stdio[2] stderr — piped + // stdio[3] — CDP write side (parent -> Chrome, net.Socket) + // stdio[4] — CDP read side (Chrome -> parent, net.Socket) + const [exe, ...chromeArgs] = buildCdpSpawnArgs(exePath, userDataDir, profile); + const chromeProc = nodeSpawn(exe, chromeArgs, { + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + }); - // Wait for Chrome to start, then find a page target's WebSocket URL. - // Network.getAllCookies is only available on page targets, not browser. - let wsUrl: string | null = null; - const startTime = Date.now(); - let loggedVersion = false; - while (Date.now() - startTime < 15_000) { - try { - // One-time version log for future diagnostics when Chrome changes v20 format. - if (!loggedVersion) { - try { - const versionResp = await fetch(`http://127.0.0.1:${debugPort}/json/version`); - if (versionResp.ok) { - const v = await versionResp.json() as { Browser?: string }; - console.log(`[cookie-import] CDP fallback: ${browser.name} ${v.Browser || 'unknown version'}`); - loggedVersion = true; - } - } catch {} - } - const resp = await fetch(`http://127.0.0.1:${debugPort}/json/list`); - if (resp.ok) { - const targets = await resp.json() as Array<{ type: string; webSocketDebuggerUrl?: string }>; - const page = targets.find(t => t.type === 'page'); - if (page?.webSocketDebuggerUrl) { - wsUrl = page.webSocketDebuggerUrl; - break; - } - } - } catch { - // Not ready yet - } - await new Promise(r => setTimeout(r, 300)); - } + // Without this listener, an async spawn failure (ENOENT, EACCES) would + // emit 'error' and crash the parent process via Node's default handler. + // Errors are surfaced via the transport's error path or the stdio null-check. + chromeProc.on('error', () => {}); - if (!wsUrl) { + const writeSocket = chromeProc.stdio[3] as NodeJS.WritableStream | null; + const readSocket = chromeProc.stdio[4] as NodeJS.ReadableStream | null; + if (!writeSocket || !readSocket) { chromeProc.kill(); throw new CookieImportError( - `${browser.name} headless did not start within 15s`, - 'cdp_timeout', - 'retry', + 'Chrome was spawned but CDP pipe endpoints (stdio[3]/[4]) are not available', + 'cdp_error', ); } + const transport = new CdpPipeTransport( + writeSocket as unknown as NodeWritable, + readSocket as unknown as NodeReadable, + ); + try { - // Connect via CDP WebSocket - const cookies = await extractCookiesViaCdp(wsUrl, domains); + // One-time version log for future diagnostics when Chrome changes v20 format. + try { + const v = await transport.send('Browser.getVersion'); + const product = (v as { product?: string }).product ?? 'unknown version'; + console.log(`[cookie-import] CDP fallback: ${browser.name} ${product}`); + } catch { + // Diagnostic only — don't fail the import if Browser.getVersion misbehaves. + } + + const cookies = await extractCookiesViaCdpPipe(transport, domains); const domainCounts: Record = {}; for (const c of cookies) { @@ -924,80 +919,11 @@ export async function importCookiesViaCdp( return { cookies, count: cookies.length, failed: 0, domainCounts }; } finally { + transport.close(); chromeProc.kill(); } } -async function extractCookiesViaCdp(wsUrl: string, domains: string[]): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(wsUrl); - let msgId = 1; - - const timeout = setTimeout(() => { - ws.close(); - reject(new CookieImportError('CDP cookie extraction timed out', 'cdp_timeout')); - }, 10_000); - - ws.onopen = () => { - // Enable Network domain first, then request all cookies - ws.send(JSON.stringify({ id: msgId++, method: 'Network.enable' })); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(String(event.data)); - - // After Network.enable succeeds, request all cookies - if (data.id === 1 && !data.error) { - ws.send(JSON.stringify({ id: msgId, method: 'Network.getAllCookies' })); - return; - } - - if (data.id === msgId && data.result?.cookies) { - clearTimeout(timeout); - ws.close(); - - // Normalize domain matching: domains like ".example.com" match "example.com" and vice versa - const domainSet = new Set(); - for (const d of domains) { - domainSet.add(d); - domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d); - } - - const matched: PlaywrightCookie[] = []; - for (const c of data.result.cookies as CdpCookie[]) { - if (!domainSet.has(c.domain)) continue; - matched.push({ - name: c.name, - value: c.value, - domain: c.domain, - path: c.path || '/', - expires: c.expires === -1 ? -1 : c.expires, - secure: c.secure, - httpOnly: c.httpOnly, - sameSite: cdpSameSite(c.sameSite), - }); - } - resolve(matched); - } else if (data.id === msgId && data.error) { - clearTimeout(timeout); - ws.close(); - reject(new CookieImportError( - `CDP error: ${data.error.message}`, - 'cdp_error', - )); - } - }; - - ws.onerror = (err) => { - clearTimeout(timeout); - reject(new CookieImportError( - `CDP WebSocket error: ${(err as any).message || 'unknown'}`, - 'cdp_error', - )); - }; - }); -} - interface CdpCookie { name: string; value: string; @@ -1020,6 +946,209 @@ function cdpSameSite(value: string): 'Strict' | 'Lax' | 'None' { } } +// ─── Internal: CDP-over-pipe transport ───────────────────────── +// Chrome's --remote-debugging-pipe exposes CDP over anonymous stdio pipes +// rather than a TCP socket. Framing is simple: UTF-8 JSON payloads delimited +// by single NUL bytes (0x00), in both directions. No length prefix, no +// WebSocket envelope. +// +// The transport accepts Node-style streams (Writable + Readable) so it can +// be unit-tested with PassThrough without spawning a real subprocess. In +// production, the streams come from `child.stdio[3]` (net.Socket, writable — +// parent→Chrome) and `child.stdio[4]` (net.Socket, readable — Chrome→parent) +// of a `node:child_process.spawn` child. +// +// Why node:child_process and not Bun.spawn: Bun.spawn exposes fds ≥ 3 as +// raw numeric file descriptors rather than stream objects, and the Node +// Writable.toWeb / Readable.toWeb bridges hang in Bun. node:child_process +// gives us net.Socket endpoints that Just Work. + +type CdpPending = { + resolve: (value: any) => void; + reject: (err: Error) => void; +}; + +export class CdpPipeTransport { + private nextId = 1; + private pending = new Map(); + private closed = false; + private writeStream: NodeWritable; + private readStream: NodeReadable; + private readBuffer = new Uint8Array(0); + private decoder = new TextDecoder(); + + constructor(writeStream: NodeWritable, readStream: NodeReadable) { + this.writeStream = writeStream; + this.readStream = readStream; + + this.readStream.on('data', (chunk: Buffer | string) => { + const bytes = typeof chunk === 'string' + ? new TextEncoder().encode(chunk) + : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + this.ingest(bytes); + }); + this.readStream.on('end', () => { + if (!this.closed) { + this.closed = true; + this.abortAll(new CookieImportError('CDP read stream ended unexpectedly', 'cdp_error')); + } + }); + this.readStream.on('error', (err: Error) => this.abortAll(err)); + this.writeStream.on('error', (err: Error) => this.abortAll(err)); + } + + send(method: string, params?: object, sessionId?: string): Promise { + if (this.closed) { + return Promise.reject(new CookieImportError('CDP transport closed', 'cdp_error')); + } + const id = this.nextId++; + const frame: Record = { id, method }; + if (params !== undefined) frame.params = params; + if (sessionId !== undefined) frame.sessionId = sessionId; + const promise = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + // Write is fire-and-forget; errors bubble via the 'error' listener above. + this.writeStream.write(JSON.stringify(frame) + '\x00'); + return promise; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.abortAll(new CookieImportError('CDP transport closed', 'cdp_error')); + try { this.writeStream.end(); } catch {} + } + + private ingest(chunk: Uint8Array): void { + // Append to accumulated buffer. + const combined = new Uint8Array(this.readBuffer.length + chunk.length); + combined.set(this.readBuffer, 0); + combined.set(chunk, this.readBuffer.length); + this.readBuffer = combined; + + // Split on NUL; last piece (no trailing NUL) becomes the new buffer. + let start = 0; + for (let i = 0; i < this.readBuffer.length; i++) { + if (this.readBuffer[i] === 0x00) { + const frame = this.readBuffer.slice(start, i); + start = i + 1; + this.handleFrame(this.decoder.decode(frame)); + } + } + this.readBuffer = this.readBuffer.slice(start); + } + + private handleFrame(text: string): void { + if (text.length === 0) return; + let msg: any; + try { + msg = JSON.parse(text); + } catch { + // Malformed frame — ignore. Real CDP does not send malformed frames; + // if we see one, it's a bug, but crashing the transport punishes the + // caller for Chrome's bug. + return; + } + + // Responses have an id. Events (no id) are ignored — we don't subscribe. + if (typeof msg.id !== 'number') return; + + const pending = this.pending.get(msg.id); + if (!pending) return; + this.pending.delete(msg.id); + + if (msg.error) { + pending.reject(new CookieImportError( + `CDP error: ${msg.error.message || 'unknown'}`, + 'cdp_error', + )); + } else { + pending.resolve(msg.result ?? {}); + } + } + + private abortAll(err: Error): void { + const wrapped = err instanceof CookieImportError + ? err + : new CookieImportError(`CDP transport error: ${err.message}`, 'cdp_error'); + for (const pending of this.pending.values()) pending.reject(wrapped); + this.pending.clear(); + } +} + +// Minimal transport surface needed by extractCookiesViaCdpPipe. Lets tests +// pass a scripted mock without constructing a real CdpPipeTransport. +export interface CdpCallable { + send(method: string, params?: object, sessionId?: string): Promise; +} + +/** + * Extract cookies over a CDP-over-pipe transport. The transport must already + * be wired to a running Chrome process with --remote-debugging-pipe. + * + * Flow: + * 1. Target.getTargets — find a page target + * 2. Target.attachToTarget — flatten session, get sessionId + * 3. Network.enable — on the page session + * 4. Network.getAllCookies — returns every cookie the browser has + * 5. Filter + map to PlaywrightCookie[] + */ +export async function extractCookiesViaCdpPipe( + transport: CdpCallable, + domains: string[], +): Promise { + if (domains.length === 0) return []; + + const targets = await transport.send('Target.getTargets'); + const pageTarget = (targets.targetInfos as Array<{ targetId: string; type: string }>) + .find(t => t.type === 'page'); + if (!pageTarget) { + throw new CookieImportError( + 'No page target found in Chrome for CDP cookie extraction', + 'cdp_error', + ); + } + + const attached = await transport.send('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); + const sessionId = attached.sessionId as string | undefined; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new CookieImportError( + 'Target.attachToTarget returned no sessionId', + 'cdp_error', + ); + } + + await transport.send('Network.enable', undefined, sessionId); + const cookiesResp = await transport.send('Network.getAllCookies', undefined, sessionId); + + // Normalize domain matching: `.example.com` should match `example.com` and vice versa. + const domainSet = new Set(); + for (const d of domains) { + domainSet.add(d); + domainSet.add(d.startsWith('.') ? d.slice(1) : '.' + d); + } + + const result: PlaywrightCookie[] = []; + for (const c of (cookiesResp.cookies ?? []) as CdpCookie[]) { + if (!domainSet.has(c.domain)) continue; + result.push({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path || '/', + expires: c.expires === -1 ? -1 : c.expires, + secure: c.secure, + httpOnly: c.httpOnly, + sameSite: cdpSameSite(c.sameSite), + }); + } + return result; +} + /** * Check if a browser's cookie DB contains v20 (App-Bound) encrypted cookies. * Quick check — reads a small sample, no decryption attempted. diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index 5e9a5b441..f8035ea26 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -19,6 +19,8 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { PassThrough } from 'node:stream'; +import { CdpPipeTransport, extractCookiesViaCdpPipe, buildCdpSpawnArgs } from '../src/cookie-import-browser'; // ─── Test Constants ───────────────────────────────────────────── @@ -517,3 +519,349 @@ describe('Cookie Import Browser', () => { }); }); }); + +// ─── CdpPipeTransport ──────────────────────────────────────────── + +// Helper: create a controllable pair of PassThrough streams mimicking Chrome's +// pipe ends. `writeToChild` is the stream the transport writes TO (Chrome +// would read). `readFromChild` is the stream the transport reads FROM (Chrome +// would write). PassThrough is structurally equivalent to the net.Socket ends +// that node:child_process.spawn returns at stdio[3]/[4] for this purpose. +function makeMockPipes() { + const writeToChild = new PassThrough(); + const readFromChild = new PassThrough(); + const writtenChunks: Buffer[] = []; + writeToChild.on('data', (chunk: Buffer) => writtenChunks.push(chunk)); + + return { + writeToChild, + readFromChild, + getWritten: () => Buffer.concat(writtenChunks).toString('utf-8'), + pushFromChild: (s: string) => readFromChild.write(s), + closeFromChild: () => readFromChild.end(), + errorFromChild: (err: Error) => readFromChild.destroy(err), + }; +} + +// Flush pending I/O ticks so writes reach the PassThrough 'data' listener. +const flush = async () => { + for (let i = 0; i < 5; i++) await new Promise(r => setImmediate(r)); +}; + +describe('CdpPipeTransport', () => { + test('send writes a NUL-terminated JSON frame with monotonic id', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + // Attach catch handlers — we never await these, but close() will reject them. + t.send('Network.enable').catch(() => {}); + t.send('Network.getAllCookies').catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe( + '{"id":1,"method":"Network.enable"}\x00' + + '{"id":2,"method":"Network.getAllCookies"}\x00' + ); + + t.close(); + }); + + test('send resolves when matching id arrives as a single frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + pipes.pushFromChild('{"id":1,"result":{}}\x00'); + const result = await pending; + expect(result).toEqual({}); + + t.close(); + }); + + test('send resolves when response arrives split across two reads at the NUL boundary', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"result":{"coo'); + pipes.pushFromChild('kies":[]}}\x00'); + const result = await pending; + expect(result).toEqual({ cookies: [] }); + + t.close(); + }); + + test('ingest handles multiple frames arriving in one chunk', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const p1 = t.send('Network.enable'); + const p2 = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"result":{}}\x00{"id":2,"result":{"cookies":[{"name":"x"}]}}\x00'); + + expect(await p1).toEqual({}); + expect(await p2).toEqual({ cookies: [{ name: 'x' }] }); + + t.close(); + }); + + test('error frame rejects the matching pending promise with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.getAllCookies'); + pipes.pushFromChild('{"id":1,"error":{"code":-32000,"message":"nope"}}\x00'); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message).toContain('nope'); + + t.close(); + }); + + test('close rejects pending promises with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + t.close(); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message.toLowerCase()).toContain('closed'); + }); + + test('unexpected read-stream end rejects pending with cdp_error', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + const pending = t.send('Network.enable'); + pipes.closeFromChild(); + + let caught: any = null; + try { await pending; } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + }); + + test('send after read-stream end rejects immediately (closed flag flipped)', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + pipes.closeFromChild(); + // Give the 'end' handler a tick to fire. + await flush(); + + let caught: any = null; + try { + await t.send('Network.enable'); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught.code).toBe('cdp_error'); + expect(caught.message.toLowerCase()).toContain('closed'); + }); + + test('send with explicit sessionId includes it in the frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + t.send('Network.enable', undefined, 'SESSION123').catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe('{"id":1,"method":"Network.enable","sessionId":"SESSION123"}\x00'); + + t.close(); + }); + + test('send with params serializes them into the frame', async () => { + const pipes = makeMockPipes(); + const t = new CdpPipeTransport(pipes.writeToChild, pipes.readFromChild); + + t.send('Target.attachToTarget', { targetId: 'T1', flatten: true }).catch(() => {}); + await flush(); + + const written = pipes.getWritten(); + expect(written).toBe( + '{"id":1,"method":"Target.attachToTarget","params":{"targetId":"T1","flatten":true}}\x00' + ); + + t.close(); + }); +}); + +// A mock transport that replays scripted responses per-method. +// The test writes scripts keyed by method name; send() returns the next +// scripted response for that method. +class ScriptedTransport { + private scripts: Record = {}; + sent: Array<{ method: string; params?: unknown; sessionId?: string }> = []; + + script(method: string, ...responses: unknown[]) { + this.scripts[method] = (this.scripts[method] ?? []).concat(responses); + } + + async send(method: string, params?: object, sessionId?: string): Promise { + this.sent.push({ method, params, sessionId }); + const queue = this.scripts[method]; + if (!queue || queue.length === 0) { + throw new Error(`No scripted response for ${method}`); + } + const next = queue.shift(); + if (next instanceof Error) throw next; + return next; + } +} + +describe('extractCookiesViaCdpPipe', () => { + test('happy path: attaches to first page target, requests cookies, filters by domain', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { + targetInfos: [ + { targetId: 'BROWSER1', type: 'browser' }, + { targetId: 'PAGE1', type: 'page', url: 'about:blank' }, + ], + }); + transport.script('Target.attachToTarget', { sessionId: 'S1' }); + transport.script('Network.enable', {}); + transport.script('Network.getAllCookies', { + cookies: [ + { name: 'sid', value: 'abc', domain: 'example.com', path: '/', expires: 1800000000, size: 4, httpOnly: true, secure: true, session: false, sameSite: 'Lax' }, + { name: 'other', value: 'xyz', domain: 'other.com', path: '/', expires: -1, size: 3, httpOnly: false, secure: false, session: true, sameSite: 'None' }, + ], + }); + + const cookies = await extractCookiesViaCdpPipe(transport as any, ['example.com']); + expect(cookies).toHaveLength(1); + expect(cookies[0]).toEqual({ + name: 'sid', + value: 'abc', + domain: 'example.com', + path: '/', + expires: 1800000000, + secure: true, + httpOnly: true, + sameSite: 'Lax', + }); + + expect(transport.sent.map(s => s.method)).toEqual([ + 'Target.getTargets', + 'Target.attachToTarget', + 'Network.enable', + 'Network.getAllCookies', + ]); + + expect(transport.sent[1].params).toEqual({ targetId: 'PAGE1', flatten: true }); + + expect(transport.sent[2].sessionId).toBe('S1'); + expect(transport.sent[3].sessionId).toBe('S1'); + }); + + test('matches cookies with leading-dot domain normalization', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { targetInfos: [{ targetId: 'P', type: 'page' }] }); + transport.script('Target.attachToTarget', { sessionId: 'S1' }); + transport.script('Network.enable', {}); + transport.script('Network.getAllCookies', { + cookies: [ + { name: 'a', value: '1', domain: '.example.com', path: '/', expires: -1, size: 1, httpOnly: false, secure: false, session: true, sameSite: 'Lax' }, + ], + }); + + const cookies = await extractCookiesViaCdpPipe(transport as any, ['example.com']); + expect(cookies).toHaveLength(1); + expect(cookies[0].domain).toBe('.example.com'); + }); + + test('throws cdp_error if no page target exists', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { + targetInfos: [{ targetId: 'BROWSER1', type: 'browser' }], + }); + + let caught: any = null; + try { + await extractCookiesViaCdpPipe(transport as any, ['example.com']); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught!.code).toBe('cdp_error'); + expect(caught!.message.toLowerCase()).toContain('page target'); + }); + + test('empty domains list short-circuits to empty result', async () => { + const transport = new ScriptedTransport(); + const cookies = await extractCookiesViaCdpPipe(transport as any, []); + expect(cookies).toEqual([]); + expect(transport.sent).toHaveLength(0); + }); + + test('returns empty array when Network.getAllCookies response has no cookies field', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { targetInfos: [{ targetId: 'P', type: 'page' }] }); + transport.script('Target.attachToTarget', { sessionId: 'S1' }); + transport.script('Network.enable', {}); + transport.script('Network.getAllCookies', {}); // no 'cookies' field + + const cookies = await extractCookiesViaCdpPipe(transport as any, ['example.com']); + expect(cookies).toEqual([]); + }); + + test('throws cdp_error when attachToTarget returns no sessionId', async () => { + const transport = new ScriptedTransport(); + transport.script('Target.getTargets', { targetInfos: [{ targetId: 'P', type: 'page' }] }); + transport.script('Target.attachToTarget', {}); // missing sessionId + + let caught: any = null; + try { + await extractCookiesViaCdpPipe(transport as any, ['example.com']); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(CookieImportError); + expect(caught.code).toBe('cdp_error'); + expect(caught.message.toLowerCase()).toContain('sessionid'); + }); +}); + +describe('buildCdpSpawnArgs', () => { + test('includes --remote-debugging-pipe', () => { + const args = buildCdpSpawnArgs('C:\\chrome.exe', 'C:\\udd', 'Default'); + expect(args).toContain('--remote-debugging-pipe'); + }); + + test('never includes --remote-debugging-port', () => { + const args = buildCdpSpawnArgs('C:\\chrome.exe', 'C:\\udd', 'Default'); + expect(args.some(a => /^--remote-debugging-port/.test(a))).toBe(false); + }); + + test('passes user-data-dir and profile-directory', () => { + const args = buildCdpSpawnArgs('C:\\chrome.exe', 'C:\\udd', 'Profile 1'); + expect(args).toContain('--user-data-dir=C:\\udd'); + expect(args).toContain('--profile-directory=Profile 1'); + }); + + test('includes headless and hardening flags', () => { + const args = buildCdpSpawnArgs('C:\\chrome.exe', 'C:\\udd', 'Default'); + for (const expected of [ + '--remote-debugging-pipe', + '--headless=new', + '--no-first-run', + '--disable-background-networking', + '--disable-default-apps', + '--disable-extensions', + '--disable-sync', + '--no-default-browser-check', + ]) { + expect(args).toContain(expected); + } + }); +});