mirror of https://github.com/garrytan/gstack.git
Merge 952a3220fd into a3259400a3
This commit is contained in:
commit
db0130ae48
|
|
@ -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<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
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<string, number> = {};
|
||||
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<PlaywrightCookie[]> {
|
||||
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<string>();
|
||||
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<number, CdpPending>();
|
||||
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<any> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new CookieImportError('CDP transport closed', 'cdp_error'));
|
||||
}
|
||||
const id = this.nextId++;
|
||||
const frame: Record<string, unknown> = { id, method };
|
||||
if (params !== undefined) frame.params = params;
|
||||
if (sessionId !== undefined) frame.sessionId = sessionId;
|
||||
const promise = new Promise<any>((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<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PlaywrightCookie[]> {
|
||||
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<string>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<string, unknown[]> = {};
|
||||
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<any> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue