mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-09 09:38:09 +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);
|
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) {
|
if (Object.keys(this.extraHeaders).length > 0) {
|
||||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||||
}
|
}
|
||||||
@@ -1446,6 +1454,9 @@ export class BrowserManager {
|
|||||||
contextOptions.userAgent = this.customUserAgent;
|
contextOptions.userAgent = this.customUserAgent;
|
||||||
}
|
}
|
||||||
this.context = await this.browser!.newContext(contextOptions);
|
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();
|
await this.newTab();
|
||||||
this.clearRefs();
|
this.clearRefs();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+16
-3
@@ -408,7 +408,11 @@ export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
|
|||||||
|
|
||||||
// Permissions API: automated Chromium returns 'denied' for notifications,
|
// Permissions API: automated Chromium returns 'denied' for notifications,
|
||||||
// a known tell. Return 'prompt' to match real Chrome (and Layer C's
|
// 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;
|
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||||
if (originalQuery) {
|
if (originalQuery) {
|
||||||
window.navigator.permissions.query = (params) => {
|
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).
|
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
||||||
* Called by browser-manager.launch(), launchHeaded(), AND handoff() so all
|
* Called by EVERY context-creation path in browser-manager — launch(),
|
||||||
* three launch paths get identical stealth.
|
* 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):
|
* Injection order (Playwright evaluates init scripts in registration order):
|
||||||
* 1. Layer C (buildStealthScript) — the always-on consistency-first default.
|
* 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
|
* pre-Pack-1) and on hosts where gbd hasn't yet populated some
|
||||||
* fields (legacy installs).
|
* 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):
|
* Mapping (gbd env → Chromium cmdline switch → C++ patch consumer):
|
||||||
* GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch
|
* GSTACK_GPU_VENDOR → --gstack-gpu-vendor → webgl-vendor-spoof.patch
|
||||||
* GSTACK_GPU_RENDERER → --gstack-gpu-renderer → 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
|
// cmdline args but never calling applyStealth, so a handed-off browser had
|
||||||
// no JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
|
// 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.
|
// This static check fails CI if any launch path drops the call again.
|
||||||
describe('stealth injected on every launch path', () => {
|
describe('stealth injected on every context-creation path', () => {
|
||||||
it('handoff() calls applyStealth and there are >= 3 call sites', async () => {
|
it('every context-creation path calls applyStealth (>= 4 call sites)', async () => {
|
||||||
const { readFileSync } = await import('node:fs');
|
const { readFileSync } = await import('node:fs');
|
||||||
const { join } = await import('node:path');
|
const { join } = await import('node:path');
|
||||||
const src = readFileSync(join(import.meta.dir, '..', 'src', 'browser-manager.ts'), 'utf-8');
|
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) || [];
|
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
|
// handoff() body specifically must call applyStealth, before the resume() JSDoc.
|
||||||
// the resume() JSDoc that follows it.
|
|
||||||
const handoffStart = src.indexOf('async handoff(');
|
const handoffStart = src.indexOf('async handoff(');
|
||||||
expect(handoffStart).toBeGreaterThan(0);
|
expect(handoffStart).toBeGreaterThan(0);
|
||||||
const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart);
|
const resumeAnchor = src.indexOf('Resume AI control after user handoff', handoffStart);
|
||||||
const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000);
|
const handoffBody = src.slice(handoffStart, resumeAnchor > 0 ? resumeAnchor : handoffStart + 4000);
|
||||||
expect(handoffBody).toContain('applyStealth(');
|
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 () => {
|
it('buildGStackLaunchArgs() is spread into all 3 launch sites', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user