From 1067b12e96040df49d4884fb1b33bfc465373323 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 18 Jun 2026 01:39:23 -0700 Subject: [PATCH] fix(browse): recreateContext() re-applies stealth (closes 4th un-stealth path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useragent and viewport --scale route through recreateContext(), which rebuilds the BrowserContext via newContext() — a fresh context with no init scripts. It never called applyStealth, so a routine useragent/viewport-scale command silently dropped webdriver masking, window.chrome.* shape, hardware spoof, and the cdc/Permissions cleanup on every restored page. Caught by the cross-model adversarial review (Codex) after the Claude pass and eng review missed it. Both the main and fallback paths now call applyStealth before any page is created. The launch-path tripwire is raised to >= 4 sites and now asserts the recreateContext() body specifically, so the regression class can't recur. Also documents the load-bearing trust assumption on buildGStackLaunchArgs / readHostProfile (GSTACK_* must be gbd-sourced, never page/remote data — the injection-safety argument depends on it) and the notifications-permission spoof tradeoff. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/src/browser-manager.ts | 11 +++++++++++ browse/src/stealth.ts | 19 +++++++++++++++--- browse/test/browser-manager-unit.test.ts | 25 ++++++++++++++++++------ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 94fcc3f5c..f9f3317b5 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -1423,6 +1423,14 @@ export class BrowserManager { } this.context = await this.browser.newContext(contextOptions); + // Re-apply stealth: newContext() is a fresh context with no init scripts, + // so a useragent / viewport --scale rebuild would otherwise drop the + // webdriver mask, window.chrome.* shape, hardware spoof, and cdc/ + // Permissions cleanup on every restored page. Must run before + // restoreState() navigates the restored tabs. + const { applyStealth } = await import('./stealth'); + await applyStealth(this.context); + if (Object.keys(this.extraHeaders).length > 0) { await this.context.setExtraHTTPHeaders(this.extraHeaders); } @@ -1446,6 +1454,9 @@ export class BrowserManager { contextOptions.userAgent = this.customUserAgent; } this.context = await this.browser!.newContext(contextOptions); + // Stealth applies to the fallback blank context too. + const { applyStealth } = await import('./stealth'); + await applyStealth(this.context); await this.newTab(); this.clearRefs(); } catch { diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index fda24d3c5..18831400a 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -408,7 +408,11 @@ export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => { // Permissions API: automated Chromium returns 'denied' for notifications, // a known tell. Return 'prompt' to match real Chrome (and Layer C's - // Notification.permission = 'default'). + // Notification.permission = 'default'). Tradeoff: this pins the + // notifications state to fresh-Chrome values for the whole session, so a + // site that actually grants/denies notifications would see a stale value. + // Acceptable for the automation/anti-detection use case (which does not + // drive real notification grants); only notifications is overridden. const originalQuery = window.navigator.permissions && window.navigator.permissions.query; if (originalQuery) { window.navigator.permissions.query = (params) => { @@ -422,8 +426,9 @@ export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => { /** * 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. + * Called by EVERY context-creation path in browser-manager — launch(), + * launchHeaded(), handoff(), AND recreateContext() (the useragent / + * viewport --scale rebuild) — so a context can never reach a page un-stealthed. * * Injection order (Playwright evaluates init scripts in registration order): * 1. Layer C (buildStealthScript) — the always-on consistency-first default. @@ -486,6 +491,14 @@ export const STEALTH_LAUNCH_ARGS = [ * pre-Pack-1) and on hosts where gbd hasn't yet populated some * fields (legacy installs). * + * TRUSTED-SOURCE ONLY. These GSTACK_* values are populated by gbd from + * host_profile.go (system_profiler / sysctl) and become page-visible WebGL / + * UA-CH surface data. They are NOT sanitized here (passed verbatim into the + * argv array, which is injection-safe because Playwright spawns Chromium with + * an argv array, not a shell). NEVER route page content, HTTP headers, or any + * remote/untrusted input into these env vars — that would turn this into an + * argv-injection sink. readHostProfile() applies the same trust assumption. + * * Mapping (gbd env → Chromium cmdline switch → C++ patch consumer): * GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch * GSTACK_GPU_RENDERER → --gstack-gpu-renderer → webgl-vendor-spoof.patch diff --git a/browse/test/browser-manager-unit.test.ts b/browse/test/browser-manager-unit.test.ts index 711ecb832..d0ef8d7a6 100644 --- a/browse/test/browser-manager-unit.test.ts +++ b/browse/test/browser-manager-unit.test.ts @@ -235,23 +235,36 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => { // 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 () => { +describe('stealth injected on every context-creation path', () => { + it('every context-creation path calls applyStealth (>= 4 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). + // Every path that builds a BrowserContext must apply stealth: launch() + // (headless), launchHeaded(), handoff(), and recreateContext() (the + // useragent / viewport --scale rebuild, main + fallback). A path that + // creates a context without applyStealth silently un-stealths its pages. const callSites = src.match(/applyStealth\(/g) || []; - expect(callSites.length).toBeGreaterThanOrEqual(3); + expect(callSites.length).toBeGreaterThanOrEqual(4); - // The handoff() method body specifically must call applyStealth, before - // the resume() JSDoc that follows it. + // handoff() body specifically must call applyStealth, before the resume() JSDoc. 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('); + + // recreateContext() body must call applyStealth too — useragent and + // viewport --scale route through it and would otherwise drop all stealth. + const recreateStart = src.indexOf('recreateContext'); + expect(recreateStart).toBeGreaterThan(0); + // Find the method definition (not just the JSDoc/caller references). + const recreateDef = src.indexOf('async recreateContext('); + expect(recreateDef).toBeGreaterThan(0); + const setUaAnchor = src.indexOf('async setDeviceScaleFactor(', recreateDef); + const recreateBody = src.slice(recreateDef, setUaAnchor > 0 ? setUaAnchor : recreateDef + 6000); + expect(recreateBody).toContain('applyStealth('); }); it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => {