From c389084a64b4d3489f01156acb6069f198e6df5d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 18 Jun 2026 00:58:34 -0700 Subject: [PATCH] fix(browse): apply stealth on every launch path + share automation-artifact cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handoff() built cmdline args but never called applyStealth, so a handed-off browser had no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). And the cdc_/Permissions cleanup shim lived inline in launchHeaded() only, so headless launch() reported Notification.permission='default' without the matching permissions.query='prompt' answer — the exact cross-source inconsistency the shim exists to prevent. Move the cleanup into AUTOMATION_ARTIFACT_CLEANUP_SCRIPT inside applyStealth so all three launch paths (launch, launchHeaded, handoff) get identical stealth, and call applyStealth(newContext) in handoff() before restoreState() navigates. A static tripwire in browser-manager-unit.test.ts fails CI if any launch path drops the applyStealth call again. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/browser-manager.ts | 55 +++++------------- browse/src/stealth.ts | 73 ++++++++++++++++++++---- browse/test/browser-manager-unit.test.ts | 27 +++++++++ browse/test/stealth-extended.test.ts | 23 +++++--- 4 files changed, 119 insertions(+), 59 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index ee1ec0907..2a81742e7 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -592,48 +592,16 @@ export class BrowserManager { this.intentionalDisconnect = false; // ─── Anti-bot-detection patches ─────────────────────────────── - // D7 (codex correction): mask navigator.webdriver only. We do NOT fake - // plugins/languages — modern fingerprinters check consistency between - // those and userAgent/platform, and synthesizing fixed values can flag - // MORE bot-like, not less. Let Chromium's natural plugins and languages - // surface unmodified. - // - // What we DO clean up are automation-specific runtime artifacts that - // shouldn't exist in a real browser at all (Permissions API quirks, - // ChromeDriver-injected window globals). Those aren't fingerprint - // synthesis — they're removing leaked automation tells. + // Apply Layer C stealth (applyStealth): masks navigator.webdriver, + // restores window.chrome.* shape, aligns Notification.permission, sets + // per-install hardware, and strips automation runtime artifacts (cdc_/ + // __webdriver globals + the Permissions notifications 'denied' tell). + // We still do NOT fake navigator.plugins/languages — faking those flags + // more bot-like, not less (D7). The cdc/Permissions cleanup moved into + // applyStealth so headless launch() and handoff() get it too, not just + // this headed path. const { applyStealth } = await import('./stealth'); await applyStealth(this.context); - await this.context.addInitScript(() => { - // Remove CDP runtime artifacts that automation detectors look for - // cdc_ prefixed vars are injected by ChromeDriver/CDP - const cleanup = () => { - for (const key of Object.keys(window)) { - if (key.startsWith('cdc_') || key.startsWith('__webdriver')) { - try { - delete (window as any)[key]; - } catch (e: any) { - if (!(e instanceof TypeError)) throw e; - } - } - } - }; - cleanup(); - // Re-clean after a tick in case they're injected late - setTimeout(cleanup, 0); - - // Override Permissions API to return 'prompt' for notifications - // (automation browsers return 'denied' which is a fingerprint) - const originalQuery = window.navigator.permissions?.query; - if (originalQuery) { - (window.navigator.permissions as any).query = (params: any) => { - if (params.name === 'notifications') { - return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus); - } - return originalQuery.call(window.navigator.permissions, params); - }; - } - }); // Inject visual indicator — subtle top-edge amber gradient // Extension's content script handles the floating pill @@ -1619,6 +1587,13 @@ export class BrowserManager { this.tabSessions.clear(); this.connectionMode = 'headed'; + // Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE + // restoreState() navigates so the init scripts apply to the restored + // pages — without this the handed-off browser had cmdline args but no + // JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). + const { applyStealth } = await import('./stealth'); + await applyStealth(newContext); + if (Object.keys(this.extraHeaders).length > 0) { await newContext.setExtraHTTPHeaders(this.extraHeaders); } diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 32c6647d0..48b040061 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -367,17 +367,69 @@ function extendedModeEnabled(): boolean { } /** - * Apply stealth patches to a fresh BrowserContext (or persistent context). - * Called by browser-manager.launch() and launchHeaded(). + * Strip automation runtime artifacts that don't belong in a real browser + * and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window + * globals (cdc_*, __webdriver*) and the Permissions API quirk where an + * automated Chromium reports notifications as 'denied' instead of real + * Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's + * Notification.permission = 'default' so the two surfaces stay consistent. * - * Always applies the always-on Layer C stealth script (built from the - * per-install host profile) — the consistency-first default. When - * GSTACK_STEALTH=extended is set, layers the opt-in EXTENDED_STEALTH_SCRIPT - * on top: its window.chrome.* patches are `if (!...)`-guarded, so Layer C's - * richer shapes win, while the extended-only additions (WebGL spoof, faked - * navigator.plugins, mediaDevices, cdc_* cleanup) apply on top. Extended - * mode actively LIES about the browser and can break sites that reflect on - * these properties, so it stays off by default. + * Runs on EVERY launch path via applyStealth (headless launch(), + * launchHeaded(), handoff()) so the Notification/Permissions pairing never + * diverges by mode. Previously this lived inline in launchHeaded() only, + * which left headless and handoff with the Notification value but not the + * matching Permissions answer. + */ +const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => { + // cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector + // finds them by iterating window keys. Strip immediately and again after a + // tick in case they are injected late. + const cleanup = () => { + for (const key of Object.keys(window)) { + if (key.startsWith('cdc_') || key.startsWith('__webdriver')) { + try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; } + } + } + }; + cleanup(); + setTimeout(cleanup, 0); + + // Permissions API: automated Chromium returns 'denied' for notifications, + // a known tell. Return 'prompt' to match real Chrome (and Layer C's + // Notification.permission = 'default'). + const originalQuery = window.navigator.permissions && window.navigator.permissions.query; + if (originalQuery) { + window.navigator.permissions.query = (params) => { + if (params && params.name === 'notifications') { + return Promise.resolve({ state: 'prompt', onchange: null }); + } + return originalQuery.call(window.navigator.permissions, params); + }; + } +})();`; + +/** + * Apply stealth patches to a fresh BrowserContext (or persistent context). + * Called by browser-manager.launch(), launchHeaded(), AND handoff() so all + * three launch paths get identical stealth. + * + * Injection order (Playwright evaluates init scripts in registration order): + * 1. Layer C (buildStealthScript) — the always-on consistency-first default. + * 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim), + * kept consistent with Layer C's Notification.permission alignment. + * 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by + * default). Its window.chrome.* patches are `if (!...)`-guarded, so + * Layer C's richer shapes win; the extended-only additions (WebGL spoof, + * faked navigator.plugins, mediaDevices) apply on top. + * + * KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT + * wrapped by Layer C's Function.prototype.toString proxy, so they stringify + * as injected code; its prototype-level navigator.webdriver delete is shadowed + * by Layer C's own-property getter (net behavior still matches real Chrome: + * webdriver present and false); and its hardcoded Apple-M1 WebGL string can + * disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple + * hosts. This is acceptable for the documented "actively lies, may break sites" + * escape hatch; the consistency-first default (Layer C alone) has none of these. * * Host profile is resolved from process.env at call time so per-install * values bake into the script before Playwright sends it to Chromium via @@ -386,6 +438,7 @@ function extendedModeEnabled(): boolean { export async function applyStealth(context: BrowserContext): Promise { const hw = readHostProfile(); await context.addInitScript({ content: buildStealthScript(hw) }); + await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT }); if (extendedModeEnabled()) { await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT }); } diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index 45bebc345..7156212c6 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -227,3 +227,30 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => { expect(shutdownCalls).toEqual([0, 2, 2]); }); }); + +// ─── Stealth injected on EVERY launch path (regression tripwire) ─── +// +// applyStealth must run on launch() (headless), launchHeaded(), AND +// handoff(). The blend of Layer C with extended mode left handoff() building +// cmdline args but never calling applyStealth, so a handed-off browser had +// no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy). +// This static check fails CI if any launch path drops the call again. +describe('stealth injected on every launch path', () => { + it('handoff() calls applyStealth and there are >= 3 call sites', async () => { + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8'); + + // >= 3 total applyStealth call sites (launch, launchHeaded, handoff). + const callSites = src.match(/applyStealth\(/g) || []; + expect(callSites.length).toBeGreaterThanOrEqual(3); + + // The handoff() method body specifically must call applyStealth, before + // the resume() JSDoc that follows it. + const handoffStart = src.indexOf('async handoff('); + expect(handoffStart).toBeGreaterThan(0); + const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart); + const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000); + expect(handoffBody).toContain('applyStealth('); + }); +}); diff --git a/browse/test/stealth-extended.test.ts b/browse/test/stealth-extended.test.ts index bc2f51436..af6a79a3b 100644 --- a/browse/test/stealth-extended.test.ts +++ b/browse/test/stealth-extended.test.ts @@ -89,7 +89,7 @@ describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => { }); describe('applyStealth — script wiring', () => { - test('default mode applies ONLY the Layer C script (not extended)', async () => { + test('default mode applies Layer C + cleanup, not extended', async () => { delete process.env.GSTACK_STEALTH; const calls: string[] = []; const fakeCtx = { @@ -98,14 +98,19 @@ describe('applyStealth — script wiring', () => { }, } as unknown as Parameters[0]; await applyStealth(fakeCtx); - expect(calls).toHaveLength(1); - // Layer C signatures: toString-proxy native-code lie + webdriver mask. + expect(calls).toHaveLength(2); + // [0] = Layer C (toString-proxy native-code lie + webdriver mask). expect(calls[0]).toContain('[native code]'); expect(calls[0]).toContain('webdriver'); - expect(calls[0]).not.toBe(EXTENDED_STEALTH_SCRIPT); + // [1] = automation-artifact cleanup (cdc_ scan + Permissions shim) — + // now applied on EVERY launch path, not just the headed one. + expect(calls[1]).toContain('cdc_'); + expect(calls[1]).toContain('setTimeout(cleanup'); + // Extended script must NOT be applied by default. + expect(calls).not.toContain(EXTENDED_STEALTH_SCRIPT); }); - test('extended mode applies BOTH scripts in order (Layer C first, extended second)', async () => { + test('extended mode applies Layer C, cleanup, then extended (in order)', async () => { process.env.GSTACK_STEALTH = 'extended'; const calls: string[] = []; const fakeCtx = { @@ -114,9 +119,9 @@ describe('applyStealth — script wiring', () => { }, } as unknown as Parameters[0]; await applyStealth(fakeCtx); - expect(calls).toHaveLength(2); - // Layer C first (its native-code lie), extended second. - expect(calls[0]).toContain('[native code]'); - expect(calls[1]).toBe(EXTENDED_STEALTH_SCRIPT); + expect(calls).toHaveLength(3); + expect(calls[0]).toContain('[native code]'); // Layer C first + expect(calls[1]).toContain('cdc_'); // cleanup second + expect(calls[2]).toBe(EXTENDED_STEALTH_SCRIPT); // extended last }); });