From 3bc13adabcfb9b172ecf00432680864d801da4cb Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 20:46:32 -0700 Subject: [PATCH 1/5] feat(browse): add CdpPipeTransport for --remote-debugging-pipe CDP Thin wrapper around two Node streams (Writable + Readable) that speaks CDP with NUL-byte-delimited JSON framing. Handles id-based request/ response correlation and error/close propagation. Not wired into anything yet; follow-up commits swap cookie-import's TCP CDP path to use this. Refs #1136. --- browse/src/cookie-import-browser.ts | 131 ++++++++++++++++++ browse/test/cookie-import-browser.test.ts | 160 ++++++++++++++++++++++ 2 files changed, 291 insertions(+) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 66328432a..f1e374830 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -40,6 +40,7 @@ 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 { TEMP_DIR } from './platform'; // ─── Types ────────────────────────────────────────────────────── @@ -1020,6 +1021,136 @@ 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.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(); + } +} + /** * 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..dfe0552f0 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 } from '../src/cookie-import-browser'; // ─── Test Constants ───────────────────────────────────────────── @@ -517,3 +519,161 @@ 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 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(); + }); +}); From c3f26cf7b5e7132349c410906dd760a52d12aca7 Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 20:52:29 -0700 Subject: [PATCH 2/5] feat(browse): add extractCookiesViaCdpPipe using CDP pipe transport Pipe-transport equivalent of extractCookiesViaCdp. Uses Target.getTargets + Target.attachToTarget(flatten: true) to acquire a page session over a single CDP pipe connection, then Network.getAllCookies. Still not wired into importCookiesViaCdp; next commit swaps the TCP path over. Refs #1136. --- browse/src/cookie-import-browser.ts | 66 +++++++++++++ browse/test/cookie-import-browser.test.ts | 110 +++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index f1e374830..acd128ba0 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -1151,6 +1151,72 @@ export class CdpPipeTransport { } } +// 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; + + 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 dfe0552f0..bf3665fb8 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { PassThrough } from 'node:stream'; -import { CdpPipeTransport } from '../src/cookie-import-browser'; +import { CdpPipeTransport, extractCookiesViaCdpPipe } from '../src/cookie-import-browser'; // ─── Test Constants ───────────────────────────────────────────── @@ -677,3 +677,111 @@ describe('CdpPipeTransport', () => { 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); + }); +}); From 5d7e494258fbfcbb9a96708ca50cdb0752439bd2 Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 21:00:24 -0700 Subject: [PATCH 3/5] fix(browse): switch cookie-import CDP from TCP port to stdio pipe Spawns Chrome with --remote-debugging-pipe instead of --remote-debugging-port, closing the v20 ABE cookie exfiltration window where any same-user local process could connect to Chrome's debug port (in the 1-3s it's listening) and call Network.getAllCookies to read decrypted v20 cookies. Pipe transport is inherited by Chrome only; no TCP socket binds, no /json/list HTTP endpoint exists. Target discovery via Target.getTargets + Target.attachToTarget(flatten: true). Browser.getVersion replaces the old /json/version fetch for diagnostics. Uses node:child_process.spawn for the Chrome subprocess because Bun.spawn exposes fds >= 3 as raw numeric fds, not streams, and Node stream-to-Web bridges hang in Bun on those sockets. The non-CDP path (direct SQLite + DPAPI on v10/v11 cookies) is unchanged; this change only affects the Windows v20 ABE fallback. Closes #1136. --- browse/src/cookie-import-browser.ts | 230 +++++++--------------- browse/test/cookie-import-browser.test.ts | 36 +++- 2 files changed, 110 insertions(+), 156 deletions(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index acd128ba0..09def036b 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -41,6 +41,7 @@ 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 ────────────────────────────────────────────────────── @@ -788,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, @@ -824,99 +864,48 @@ 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)); - } - - 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 import('node:stream').Writable, + readSocket as unknown as import('node:stream').Readable, + ); + 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) { @@ -925,80 +914,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; diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index bf3665fb8..d9776bbb9 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { PassThrough } from 'node:stream'; -import { CdpPipeTransport, extractCookiesViaCdpPipe } from '../src/cookie-import-browser'; +import { CdpPipeTransport, extractCookiesViaCdpPipe, buildCdpSpawnArgs } from '../src/cookie-import-browser'; // ─── Test Constants ───────────────────────────────────────────── @@ -785,3 +785,37 @@ describe('extractCookiesViaCdpPipe', () => { expect(transport.sent).toHaveLength(0); }); }); + +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); + } + }); +}); From 7df4295f1030f4f34bc4c2edeb60634ee3d57759 Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 21:07:18 -0700 Subject: [PATCH 4/5] fix(browse): handle chromeProc spawn error events Adds an error listener on the Chrome child process. Without it, async spawn failures (ENOENT, EACCES, antivirus interference) emit 'error' with no handler and crash the parent process via Node's default behavior. Errors surface via the transport's error path or the stdio null-check guard. Also tightens type casts at the CdpPipeTransport call site to reuse the existing NodeWritable/NodeReadable aliases instead of inline import('node:stream') references. Refs #1136. --- browse/src/cookie-import-browser.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 09def036b..e8d793ea6 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -880,6 +880,11 @@ export async function importCookiesViaCdp( stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], }); + // 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', () => {}); + const writeSocket = chromeProc.stdio[3] as NodeJS.WritableStream | null; const readSocket = chromeProc.stdio[4] as NodeJS.ReadableStream | null; if (!writeSocket || !readSocket) { @@ -891,8 +896,8 @@ export async function importCookiesViaCdp( } const transport = new CdpPipeTransport( - writeSocket as unknown as import('node:stream').Writable, - readSocket as unknown as import('node:stream').Readable, + writeSocket as unknown as NodeWritable, + readSocket as unknown as NodeReadable, ); try { From 952a3220fd871c0412269710f74a30df0fa1f099 Mon Sep 17 00:00:00 2001 From: Nicolas Gomes Ferreira Dos Santos Date: Thu, 23 Apr 2026 21:32:25 -0700 Subject: [PATCH 5/5] fix(browse): defensive hardening for cookie-import CDP pipe path Three narrow robustness fixes flagged during review: 1. CdpPipeTransport.readStream 'end' handler now sets this.closed = true before aborting pending. Without this, a send() after end would queue a promise forever (the 'closed' guard wouldn't trigger). 2. extractCookiesViaCdpPipe guards cookiesResp.cookies with ?? [] so a CDP response missing the cookies field returns an empty array instead of throwing TypeError. 3. extractCookiesViaCdpPipe validates attached.sessionId is a non-empty string. flatten:true should always return one, but defensive guards beat silent fallback to the browser-level session. Three new tests lock each fix: - send-after-end rejects with cdp_error/closed - empty getAllCookies response returns [] - attachToTarget without sessionId throws cdp_error Refs #1136. --- browse/src/cookie-import-browser.ts | 11 +++++- browse/test/cookie-import-browser.test.ts | 46 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index e8d793ea6..9029c2fcd 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -989,6 +989,7 @@ export class CdpPipeTransport { }); this.readStream.on('end', () => { if (!this.closed) { + this.closed = true; this.abortAll(new CookieImportError('CDP read stream ended unexpectedly', 'cdp_error')); } }); @@ -1113,7 +1114,13 @@ export async function extractCookiesViaCdpPipe( targetId: pageTarget.targetId, flatten: true, }); - const sessionId = attached.sessionId as string; + 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); @@ -1126,7 +1133,7 @@ export async function extractCookiesViaCdpPipe( } const result: PlaywrightCookie[] = []; - for (const c of cookiesResp.cookies as CdpCookie[]) { + for (const c of (cookiesResp.cookies ?? []) as CdpCookie[]) { if (!domainSet.has(c.domain)) continue; result.push({ name: c.name, diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index d9776bbb9..f8035ea26 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -649,6 +649,25 @@ describe('CdpPipeTransport', () => { 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); @@ -784,6 +803,33 @@ describe('extractCookiesViaCdpPipe', () => { 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', () => {