mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-19 00:00:13 +02:00
fix(browse): recreateContext() re-applies stealth (closes 4th un-stealth path)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user