v1.58.3.0 feat: gbrowser anti-detection Layer C stealth (#2047)

* feat: Layer C stealth — chrome.*, Notification, per-install hardware, toString Proxy (gbrowser T1+T3+D6)

Three additions stacked into the existing applyStealth() init script
to close the visible automation tells that today push GBrowser users
into Google's /sorry/index captcha and similar:

T1 — Strip Playwright's automation default args:
  --enable-automation                              (kills "Chrome is being
                                                    controlled" infobar)
  --disable-popup-blocking, --disable-component-update,
  --disable-default-apps                           (Patchright's list — each
                                                    is a documented tell)

  Now centralized in STEALTH_IGNORE_DEFAULT_ARGS export, used by BOTH
  launchHeaded() and handoff() (the headless → headed re-launch path).

D6 — Drop "GStackBrowser" UA branding suffix:
  Real Chrome's UA ends `Safari/537.36`, not `Safari/537.36 GStackBrowser`.
  The branded suffix was a high-entropy classifier for any vendor that
  grep'd UA for known automation/test-browser strings. Branding still
  lives in the wrapper .app name + Dock icon + tray — does not need
  to leak via the UA string for the product to be "GBrowser." Resolves
  the "looks like Chrome but identifies as GStackBrowser" contradiction
  codex review #18 flagged.

T3 — Layer C init-script additions in stealth.ts:

  1. Function.prototype.toString Proxy (must run first). Wraps every
     patched getter / function in a WeakSet so they report
     `function NAME() { [native code] }` at every recursion depth,
     defeating the depth-3+ integrity check
     (fn.toString.toString.toString().includes('[native code]')).

  2. window.chrome.runtime / chrome.app / chrome.csi / chrome.loadTimes
     restoration with full enum shape (OnInstalledReason, PlatformArch,
     PlatformOs, etc.) + method bodies. Real Chrome ships these; their
     absence is universally checked. Vendor research (gbrowser plan
     deep-dive on Cloudflare + DataDome) confirmed both vendors probe
     this shape directly.

  3. Notification.permission aligned to 'default'. The existing inline
     addInitScript already spoofs permissions.query({name:'notifications'})
     to return 'prompt' — Notification.permission being 'denied' while
     Permissions returns 'prompt' is a cross-source inconsistency that
     detectors flag specifically.

  4. Per-install hardware values via GSTACK_HW_CONCURRENCY /
     GSTACK_DEVICE_MEMORY env vars (set by gbd's host_profile.go from
     system_profiler + sysctl). Reporting real host values within the
     Chrome shape avoids the cross-user GBrowser fingerprint cluster
     that hardcoded defaults would create. Codex review #10 flagged
     hardcoding as creating contradictions across Apple Silicon / Intel
     / UA-CH architecture.

  5. Selenium 25-global cleanup + PhantomJS + NightmareJS + Watir +
     Playwright (__pwInitScripts, __playwright__binding__) static-name
     deletion. The inline block continues to handle the dynamic
     cdc_/__webdriver/__selenium/__driver prefixes.

D7 (codex correction) kept: still do NOT fake navigator.plugins or
navigator.languages. Synthesizing those triggers MORE consistency
flags from modern fingerprinters than letting Chromium surface them
natively.

Test coverage:
- 15 new tests in stealth-layer-c.test.ts covering: launch-flag
  exports, script structure, toString-Proxy installs first, every
  spoof present, hardware values interpolated from input (not
  hardcoded), Selenium global cleanup spot-check, no GStackBrowser
  leak in stealth payload, backwards-compat exports preserved.
- All 8 existing stealth-webdriver tests still pass.
- All 2 existing browser-manager-unit tests still pass.

For GBrowser specifically: this is the gstack-side half of Phase 1 / T1
+ T3 + D6 in the anti-detection plan. The gbrowser repo's submodule
pointer bump will land alongside this.

* feat: buildGStackLaunchArgs — Pack 1 cmdline-switch construction for gbrowser

New stealth.ts export that turns the GSTACK_* env vars (already populated
by gbrowser's gbd from host_profile.go) into the --gstack-* cmdline
switches the Pack 1 Chromium patches read at WebGL getParameter,
NavigatorUA::userAgentData, NavigatorConcurrentHardware::hardwareConcurrency,
and NavigatorDeviceMemory::deviceMemory time.

Wired into all three launchArgs sites: launch() (headless), launchHeaded()
(real product path), and handoff() (headless → headed re-launch).

Mapping:
  GSTACK_GPU_VENDOR      → --gstack-gpu-vendor
  GSTACK_GPU_RENDERER    → --gstack-gpu-renderer
  GSTACK_PLATFORM        → --gstack-ua-platform (with mapping:
                            MacARM/MacIntel → macOS, Win32 → Windows,
                            Linux x86_64 → Linux)
  GSTACK_GPU_CHIPSET     → --gstack-ua-model
  GSTACK_HW_CONCURRENCY  → --gstack-hw-concurrency
  GSTACK_DEVICE_MEMORY   → --gstack-device-memory

Each switch is emitted only when its env var is non-empty — empty
values fall through to the patch's "no override" path, which returns
the real Chromium native value. Safe to ship on Chromium builds
without the Pack 1 patches applied (zero behavior change).

The patches themselves live in the gbrowser repo at chromium/patches/
{webgl-vendor-spoof,ua-client-hints-stealth,worker-navigator-stealth}.patch.
Both halves (gstack arg construction + gbrowser C++ patches) must
land + Chromium rebuild before the spoof reaches the WebGL/UA-CH/
hardware accessors. Currently dormant until then.

Tests (browse/test/stealth-layer-c.test.ts):
  7 new buildGStackLaunchArgs cases — empty env, all-populated, partial,
  platform mapping (MacARM/MacIntel/Win32/Linux), unrecognized platform
  fallthrough, vendor-with-spaces escape-safety.
  All 32 stealth/browser-manager tests pass.

For GBrowser specifically: gstack-side half of the Pack 1 flag plumbing.
gbrowser repo will bump the submodule pointer to this commit, then re-run
bun run test/anti-bot/evidence-run.ts to verify creepjs's "33% headless"
score drops after Pack 1 + Chromium rebuild.

* feat: buildGStackLaunchArgs adds --gstack-suppress-prepare-stack-trace

Pack 2 / B11 flag plumbing for the new
error-preparestacktrace-stealth.patch in gbrowser/chromium/patches/.

Always emit --gstack-suppress-prepare-stack-trace unless the caller
explicitly sets GSTACK_CDP_STEALTH=off in the environment. Off by
default in patch behavior (no-op without the C++ patch), so this is
safe on stock Playwright Chromium too.

Closes the Cloudflare canary trick where a page sets
Error.prepareStackTrace and watches for it to fire during CDP
serialization of a logged Error object.

Tests:
  All 33 stealth/browser-manager tests pass. New cases:
  - GSTACK_CDP_STEALTH=off disables suppression
  - empty env still emits the always-on flag (count=1)
  - all-populated env now emits 7 flags (was 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(browse): enable Chromium sandbox on headed launchPersistentContext

Mirrors v1.40.0.1 from main lineage (PR #1617). Cherry-picked onto
gbrowser-anti-detection so the GBrowser submodule can consume the fix
without waiting for main to merge.

Playwright auto-adds --no-sandbox whenever chromiumSandbox !== true
(playwright-core/lib/server/chromium/chromium.js:291-292). The headless
chromium.launch() site set the option; the two headed sites
(launchHeaded() and handoff()) did not. Every headed launch on macOS
and Linux showed Chromium's yellow "unsupported command-line flag:
--no-sandbox" infobar.

shouldEnableChromiumSandbox() centralizes the Win32 / CI / CONTAINER /
root heuristic that previously lived only in the headless path's
explicit --no-sandbox push at :225. All three launch sites now use the
helper, and six unit tests pin the policy across darwin, linux, win32,
CI, CONTAINER, and root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v1.40.0.2 fix(browse): Cmd+Q on managed Chromium stops triggering supervisor respawn

Three browser.on('disconnected') handlers in browse/src/browser-manager.ts
(launch, launchHeaded, handoff) each exited with a non-zero code on every
disconnect, regardless of cause. Process supervisors that consume our exit
code (gbrowser's gbd HealthMonitor in cmd/gbd/health.go) treated user
Cmd+Q identical to a Chromium crash and respawned with exponential
backoff, so the visible browser kept reappearing after the user closed it.

Add resolveDisconnectCause(browser) that reads the underlying ChildProcess
exitCode + signalCode (waiting up to 1s for the exit event if the
disconnected event fired first). Exit code 0 + no signal = clean user
quit; anything else = crash, signal-kill, or OOM.

Wire the resolver into all three disconnect handlers:
- launch() (headless): clean → exit 0, crash → exit 1 (was always 1)
- launchHeaded() (headed): clean → exit 0, crash → exit 2 (was always 2)
  onDisconnect() cleanup callback still runs in both cases.
- handoff() (re-launch): same as launch() via the helper.

Preserve the per-path crash codes (1 vs 2) so any supervisor that
differentiated headed vs headless crashes keeps working.

Seven new unit tests in browse-manager-unit.test.ts cover the resolver
across already-exited, signal-killed (SIGSEGV / SIGKILL), async exits,
and null-browser inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(browse): apply stealth on every launch path + share automation-artifact cleanup

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) <noreply@anthropic.com>

* fix(browse): make --gstack-suppress-prepare-stack-trace opt-in, not default-on

buildGStackLaunchArgs() pushed the flag unless GSTACK_CDP_STEALTH=off, i.e.
on-by-default — contradicting its own comment ("off by default, only for
gbrowser builds"). The switch is read by a C++ patch that only exists in
gbrowser; on stock Playwright Chromium it is an unknown switch.

Flip to opt-in: emit only when GSTACK_CDP_STEALTH is on/1/true. gbd opts in by
exporting GSTACK_CDP_STEALTH=on; stock installs leave it unset so the flag
never reaches a Chromium that wouldn't understand it. Comment now matches code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(browse): correct stale stealth comments

The file-level stealth.ts docstring claimed "we DON'T fake navigator.plugins"
while the same file now ships EXTENDED_STEALTH_SCRIPT, which does fake plugins
when GSTACK_STEALTH=extended. Clarify that Layer C (the always-on default)
doesn't fake plugins and the opt-in extended mode does, as the documented
"actively lies, may break sites" escape hatch.

Also fix the launch()/launchHeaded() comments that said "mask navigator.webdriver
only" — applyStealth (Layer C) also restores window.chrome.*, aligns
Notification.permission, and sets per-install hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(browse): runtime + extended-mode coverage for the stealth blend

The stealth tests were all static string-shape assertions; nothing executed
the script in a real page. Add real-Chromium runtime checks via applyStealth +
page.evaluate:

- Layer C runtime: window.chrome.* rich shape, Notification.permission='default'
  paired with permissions.query notifications='prompt' (guards the shim now
  running on every path), and patched getters reporting [native code].
- Per-install hardware: navigator.hardwareConcurrency/deviceMemory reflect the
  GSTACK_* env profile.
- Extended-mode blend: navigator.plugins is faked when GSTACK_STEALTH=extended,
  Layer C still wins window.chrome.runtime, and navigator.webdriver stays false
  (own-prop getter survives extended's prototype delete).
- Persistent-context (launchHeaded/handoff) parity now uses a page created
  AFTER applyStealth — the old test checked pages()[0], which predates the init
  script, so webdriver was false only via the launch arg, not Layer C.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(browse): handoff() + launchHeaded() spread the shared STEALTH_LAUNCH_ARGS

handoff() built its launch args from only ['--hide-crash-restore-bubble',
...buildGStackLaunchArgs()], omitting STEALTH_LAUNCH_ARGS — so a handed-off
browser kept the --disable-blink-features=AutomationControlled tell that
launch() and launchHeaded() strip. launchHeaded() also hardcoded the flag as a
literal. Both now spread the shared constant, so the AutomationControlled flag
lives in one place across all three launch paths.

Tripwires: STEALTH_LAUNCH_ARGS spread into >= 3 sites (no inline literal) and
STEALTH_IGNORE_DEFAULT_ARGS wired into both persistent-context paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(browse): drop dead HostProfile.platform, export test internals

HostProfile.platform was set by readHostProfile but never read by
buildStealthScript — the platform spoof is owned by the UA-CH cmdline switch in
buildGStackLaunchArgs (which reads GSTACK_PLATFORM directly). Remove the dead
field. Export readHostProfile and AUTOMATION_ARTIFACT_CLEANUP_SCRIPT so their
clamp/shape invariants can be unit-tested. Correct the stale "25 Selenium
globals" count comment and note the extended cdc_ scan is redundant-but-retained
for standalone use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(browse): cover readHostProfile clamp, toString depth-3, chrome.* calls

Pre-landing review coverage gaps:
- readHostProfile clamps 0/negative/NaN/missing env to 8 (a deviceMemory=0 or
  NaN would be a glaring bot tell) — now asserted.
- toString proxy survives the depth-3 recursion trick
  (fn.toString.toString.toString().includes('[native code]')), the headline
  claim that was only tested at depth-1.
- chrome.csi() and chrome.loadTimes() are invoked (not just typeof-checked) and
  runtime.connect() throws the native-shaped "No matching signature" error.
- AUTOMATION_ARTIFACT_CLEANUP_SCRIPT static shape (cdc_/__webdriver strip +
  notifications->prompt) as a hermetic backup for the live-Chromium pairing test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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>

* chore: bump version and changelog (v1.58.3.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: sync browser stealth docs to Layer C (v1.58.3.0)

BROWSER.md "Stealth scope" still described the default as navigator.webdriver
masking only; Layer C is now the always-on default across all four
context-creation paths. Update the stealth-scope prose, the "What GStack
Browser means" blurb (stock-Chrome UA, no GStackBrowser suffix, captchas can
still get through at the CDP layer), the stealth.ts source-map line, and the
env-vars table (GSTACK_STEALTH, GSTACK_CDP_STEALTH, GSTACK_GPU_*, GSTACK_PLATFORM,
GSTACK_HW_CONCURRENCY/GSTACK_DEVICE_MEMORY + the explicit --gstack-* switches and
ignoreDefaultArgs stripping). Correct the stale "narrows to navigator.webdriver
masking only" premise on the open CDP-patch TODO (the TODO itself stays open —
the CDP-protocol layer is still unaddressed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-18 10:45:05 -07:00
committed by GitHub
parent c7ae63201a
commit a861c00cfa
11 changed files with 1190 additions and 138 deletions
+76
View File
@@ -227,3 +227,79 @@ 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 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');
// 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(4);
// 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 () => {
// Same silent-drop regression class as applyStealth: a launch path that
// omits buildGStackLaunchArgs() loses the per-install GPU/UA/hardware
// cmdline spoof. launch(), launchHeaded(), and handoff() must all call it.
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');
const callSites = src.match(/buildGStackLaunchArgs\(\)/g) || [];
expect(callSites.length).toBeGreaterThanOrEqual(3);
});
it('STEALTH_LAUNCH_ARGS is spread into all 3 launch sites (no hardcoded literal)', async () => {
// The --disable-blink-features=AutomationControlled flag must come from the
// shared constant on launch(), launchHeaded(), AND handoff(). handoff()
// previously omitted it, leaving the AutomationControlled tell on the
// handed-off browser. No path may inline the literal instead.
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');
const spreads = src.match(/\.\.\.STEALTH_LAUNCH_ARGS/g) || [];
expect(spreads.length).toBeGreaterThanOrEqual(3);
// The literal must not be reintroduced in a launchArgs array (it belongs in
// the STEALTH_LAUNCH_ARGS constant in stealth.ts, not inline here).
expect(src).not.toContain("'--disable-blink-features=AutomationControlled'");
});
it('STEALTH_IGNORE_DEFAULT_ARGS is wired into both persistent-context paths', async () => {
// launchHeaded() and handoff() both launchPersistentContext and must strip
// Playwright's automation-tell defaults via the shared constant.
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');
const sites = src.match(/ignoreDefaultArgs:\s*STEALTH_IGNORE_DEFAULT_ARGS/g) || [];
expect(sites.length).toBeGreaterThanOrEqual(2);
});
});
+21 -12
View File
@@ -3,11 +3,12 @@
* v1.41 wave).
*
* Pins:
* 1. Default mode keeps minimum: only WEBDRIVER_MASK_SCRIPT applied.
* 2. GSTACK_STEALTH=extended adds EXTENDED_STEALTH_SCRIPT on top.
* 1. Default mode applies the always-on Layer C stealth script (and NOT
* the extended script) — the consistency-first default.
* 2. GSTACK_STEALTH=extended adds EXTENDED_STEALTH_SCRIPT on top of Layer C.
* 3. EXTENDED_STEALTH_SCRIPT contains the six detection-vector patches.
* 4. Apply order: default mask first, extended second (so the
* delete-from-prototype path layers on top of the getter without
* 4. Apply order: Layer C first, extended second (so the extended
* delete-from-prototype path layers on top of Layer C's getter without
* silently overriding it if delete fails).
*
* Live SannySoft pass-rate verification is a periodic-tier E2E test
@@ -18,7 +19,6 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
EXTENDED_STEALTH_SCRIPT,
WEBDRIVER_MASK_SCRIPT,
isExtendedStealthEnabled,
applyStealth,
} from '../src/stealth';
@@ -89,7 +89,7 @@ describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => {
});
describe('applyStealth — script wiring', () => {
test('default mode applies ONLY WEBDRIVER_MASK_SCRIPT', async () => {
test('default mode applies Layer C + cleanup, not extended', async () => {
delete process.env.GSTACK_STEALTH;
const calls: string[] = [];
const fakeCtx = {
@@ -98,11 +98,19 @@ describe('applyStealth — script wiring', () => {
},
} as unknown as Parameters<typeof applyStealth>[0];
await applyStealth(fakeCtx);
expect(calls).toHaveLength(1);
expect(calls[0]).toBe(WEBDRIVER_MASK_SCRIPT);
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');
// [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 (mask 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 = {
@@ -111,8 +119,9 @@ describe('applyStealth — script wiring', () => {
},
} as unknown as Parameters<typeof applyStealth>[0];
await applyStealth(fakeCtx);
expect(calls).toHaveLength(2);
expect(calls[0]).toBe(WEBDRIVER_MASK_SCRIPT);
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
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* stealth.ts Layer C additions (T3 + D6 for GBrowser anti-detection):
* verifies the build-time scaffolding without requiring a live browser.
*
* Live-browser verification of these spoofs in actual page contexts is
* covered by the gbrowser-side `test/anti-bot.test.sh` (Phase 1 / T7)
* which loads the probe page through the built GBrowser app post-bundle.
* These tests only exercise the JS script builder + the static export
* shapes — fast, hermetic, no chromium launch.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import {
buildStealthScript,
buildGStackLaunchArgs,
readHostProfile,
AUTOMATION_ARTIFACT_CLEANUP_SCRIPT,
WEBDRIVER_MASK_SCRIPT,
STEALTH_LAUNCH_ARGS,
STEALTH_IGNORE_DEFAULT_ARGS,
} from '../src/stealth';
describe('STEALTH_IGNORE_DEFAULT_ARGS — T1', () => {
test('includes --enable-automation (kills infobar)', () => {
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--enable-automation');
});
test('includes the 4 Patchright-recommended adds', () => {
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-popup-blocking');
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-update');
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-default-apps');
});
test('preserves the original extension-loading blockers', () => {
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-extensions');
expect(STEALTH_IGNORE_DEFAULT_ARGS).toContain('--disable-component-extensions-with-background-pages');
});
});
describe('buildStealthScript — T3 Layer C', () => {
const hw = { platform: 'MacARM', hwConcurrency: 16, deviceMemory: 8 };
test('builds a self-invoking function (atomic injection)', () => {
const s = buildStealthScript(hw);
expect(s.trim().startsWith('(() => {')).toBe(true);
expect(s.trim().endsWith('})();')).toBe(true);
});
test('installs the Function.prototype.toString Proxy FIRST', () => {
const s = buildStealthScript(hw);
const proxyIdx = s.indexOf('new Proxy(nativeToString');
const webdriverIdx = s.indexOf("'webdriver'");
expect(proxyIdx).toBeGreaterThan(0);
expect(webdriverIdx).toBeGreaterThan(proxyIdx);
});
test('navigator.webdriver getter returns false', () => {
const s = buildStealthScript(hw);
expect(s).toMatch(/Object\.defineProperty\(navigator, 'webdriver'/);
expect(s).toMatch(/return false/);
});
test('window.chrome.runtime ships full enum shape', () => {
const s = buildStealthScript(hw);
expect(s).toContain('OnInstalledReason');
expect(s).toContain('PlatformArch');
expect(s).toContain('PlatformOs');
expect(s).toContain('RequestUpdateCheckStatus');
// sendMessage / connect must throw native-shaped errors
expect(s).toContain('runtime.connect');
expect(s).toContain('runtime.sendMessage');
});
test('chrome.csi and chrome.loadTimes provide method bodies', () => {
const s = buildStealthScript(hw);
expect(s).toContain('chrome.csi = markNative(function csi()');
expect(s).toContain('chrome.loadTimes = markNative(function loadTimes()');
// loadTimes shape must include wasFetchedViaSpdy/connectionInfo —
// those are what real Chrome's loadTimes() returns on HTTP/2 sites.
expect(s).toContain('wasFetchedViaSpdy');
expect(s).toContain('connectionInfo');
});
test('Notification.permission aligned to default', () => {
const s = buildStealthScript(hw);
expect(s).toMatch(/Notification, 'permission'/);
expect(s).toMatch(/return 'default'/);
});
test('hardware values interpolated from host profile (NOT hardcoded)', () => {
const s = buildStealthScript({ platform: 'MacARM', hwConcurrency: 12, deviceMemory: 4 });
expect(s).toContain('return 12');
expect(s).toContain('return 4');
expect(s).not.toMatch(/return 8;.*hardwareConcurrency/);
});
test('cleans up Selenium + Playwright + Phantom + Nightmare globals', () => {
const s = buildStealthScript(hw);
// Spot-check a few from each category
expect(s).toContain('__webdriver_evaluate'); // Selenium
expect(s).toContain('domAutomationController'); // Chrome Driver classic
expect(s).toContain('__pwInitScripts'); // Playwright
expect(s).toContain('callPhantom'); // PhantomJS
expect(s).toContain('__nightmare'); // NightmareJS
expect(s).toContain('_Selenium_IDE_Recorder'); // Selenium IDE
});
test('uses markNative wrapper for every patched function', () => {
const s = buildStealthScript(hw);
// Every getter (hardwareConcurrency, deviceMemory, webdriver, Notification.permission)
// should be wrapped through markNative so the toString Proxy covers it.
const markNativeMatches = s.match(/markNative\(/g) || [];
// At least 8 markNative wrappings (webdriver, csi, loadTimes, connect, sendMessage,
// notification permission, hwConcurrency, deviceMemory)
expect(markNativeMatches.length).toBeGreaterThanOrEqual(7);
});
test('script does not include "GStackBrowser" branding string', () => {
const s = buildStealthScript(hw);
// D6: dropped from UA, must not leak in via stealth payload either.
expect(s).not.toContain('GStackBrowser');
});
});
describe('buildGStackLaunchArgs — Pack 1 cmdline-switch construction', () => {
// Helper: clear all GSTACK_* env, run test body, restore env.
function withEnv(env: Record<string, string | undefined>, body: () => void) {
const saved: Record<string, string | undefined> = {};
for (const k of Object.keys(process.env)) {
if (k.startsWith('GSTACK_')) {
saved[k] = process.env[k];
delete process.env[k];
}
}
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) process.env[k] = v;
}
try {
body();
} finally {
for (const k of Object.keys(process.env)) {
if (k.startsWith('GSTACK_')) delete process.env[k];
}
for (const [k, v] of Object.entries(saved)) {
if (v !== undefined) process.env[k] = v;
}
}
}
test('empty env produces no switches (suppress-stack is opt-in)', () => {
withEnv({}, () => {
// All switches are opt-in: the six per-install flags fall through
// (nothing in env), and the Pack 2 / B11 suppression flag is only
// emitted when GSTACK_CDP_STEALTH is on/1/true. Empty env → [].
expect(buildGStackLaunchArgs()).toEqual([]);
});
});
test('all env values populated (incl. CDP stealth opt-in) → all 7 switches emitted', () => {
withEnv({
GSTACK_GPU_VENDOR: 'Apple Inc.',
GSTACK_GPU_RENDERER: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)',
GSTACK_PLATFORM: 'MacARM',
GSTACK_GPU_CHIPSET: 'Apple M4 Max',
GSTACK_HW_CONCURRENCY: '16',
GSTACK_DEVICE_MEMORY: '8',
GSTACK_CDP_STEALTH: 'on',
}, () => {
const args = buildGStackLaunchArgs();
expect(args).toContain('--gstack-gpu-vendor=Apple Inc.');
expect(args).toContain('--gstack-gpu-renderer=ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)');
expect(args).toContain('--gstack-ua-platform=macOS');
expect(args).toContain('--gstack-ua-model=Apple M4 Max');
expect(args).toContain('--gstack-hw-concurrency=16');
expect(args).toContain('--gstack-device-memory=8');
expect(args).toContain('--gstack-suppress-prepare-stack-trace');
expect(args.length).toBe(7);
});
});
test('platform mapping: MacARM and MacIntel both → macOS', () => {
withEnv({ GSTACK_PLATFORM: 'MacARM' }, () => {
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS');
});
withEnv({ GSTACK_PLATFORM: 'MacIntel' }, () => {
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=macOS');
});
});
test('platform mapping: Win32 → Windows, Linux x86_64 → Linux', () => {
withEnv({ GSTACK_PLATFORM: 'Win32' }, () => {
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Windows');
});
withEnv({ GSTACK_PLATFORM: 'Linux x86_64' }, () => {
expect(buildGStackLaunchArgs()).toContain('--gstack-ua-platform=Linux');
});
});
test('partial env: only set switches that have values', () => {
withEnv({ GSTACK_HW_CONCURRENCY: '12' }, () => {
const args = buildGStackLaunchArgs();
// hw only — suppress-stack is opt-in and GSTACK_CDP_STEALTH is unset.
expect(args).toContain('--gstack-hw-concurrency=12');
expect(args).not.toContain('--gstack-suppress-prepare-stack-trace');
expect(args.length).toBe(1);
});
});
test('prepare-stack-trace suppression is opt-in via GSTACK_CDP_STEALTH', () => {
// on/1/true enable it; off and unset omit it, so stock Playwright
// Chromium (no GSTACK_CDP_STEALTH) never receives the unknown switch.
for (const v of ['on', '1', 'true']) {
withEnv({ GSTACK_CDP_STEALTH: v }, () => {
expect(buildGStackLaunchArgs()).toContain('--gstack-suppress-prepare-stack-trace');
});
}
withEnv({ GSTACK_CDP_STEALTH: 'off' }, () => {
expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace');
});
withEnv({}, () => {
expect(buildGStackLaunchArgs()).not.toContain('--gstack-suppress-prepare-stack-trace');
});
});
test('unrecognized platform falls through without --gstack-ua-platform', () => {
withEnv({ GSTACK_PLATFORM: 'OS/2' }, () => {
const args = buildGStackLaunchArgs();
expect(args.some(a => a.startsWith('--gstack-ua-platform='))).toBe(false);
});
});
test('GPU vendor with spaces survives intact (no quote/escape regression)', () => {
withEnv({ GSTACK_GPU_VENDOR: 'NVIDIA Corporation' }, () => {
const args = buildGStackLaunchArgs();
expect(args).toContain('--gstack-gpu-vendor=NVIDIA Corporation');
});
});
});
describe('backwards-compat exports', () => {
test('WEBDRIVER_MASK_SCRIPT still exported', () => {
expect(WEBDRIVER_MASK_SCRIPT).toContain("'webdriver'");
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
});
test('STEALTH_LAUNCH_ARGS still includes blink-features=AutomationControlled', () => {
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
});
});
describe('readHostProfile — clamp/fallback', () => {
let savedHw: string | undefined;
let savedMem: string | undefined;
afterEach(() => {
if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY;
else process.env.GSTACK_HW_CONCURRENCY = savedHw;
if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY;
else process.env.GSTACK_DEVICE_MEMORY = savedMem;
});
function withHw(hw: string | undefined, mem: string | undefined): ReturnType<typeof readHostProfile> {
savedHw = process.env.GSTACK_HW_CONCURRENCY;
savedMem = process.env.GSTACK_DEVICE_MEMORY;
if (hw === undefined) delete process.env.GSTACK_HW_CONCURRENCY; else process.env.GSTACK_HW_CONCURRENCY = hw;
if (mem === undefined) delete process.env.GSTACK_DEVICE_MEMORY; else process.env.GSTACK_DEVICE_MEMORY = mem;
return readHostProfile();
}
test('valid env values pass through', () => {
expect(withHw('16', '8')).toEqual({ hwConcurrency: 16, deviceMemory: 8 });
});
test('missing env → default 8/8', () => {
expect(withHw(undefined, undefined)).toEqual({ hwConcurrency: 8, deviceMemory: 8 });
});
test('zero / negative / NaN / empty all clamp to 8 (never a 0 or NaN bot tell)', () => {
for (const bad of ['0', '-4', 'abc', '']) {
const p = withHw(bad, bad);
expect(p.hwConcurrency).toBe(8);
expect(p.deviceMemory).toBe(8);
}
});
});
describe('AUTOMATION_ARTIFACT_CLEANUP_SCRIPT — static shape', () => {
test('strips cdc_/__webdriver and maps notifications query to prompt', () => {
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('cdc_')");
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("startsWith('__webdriver')");
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("name === 'notifications'");
expect(AUTOMATION_ARTIFACT_CLEANUP_SCRIPT).toContain("state: 'prompt'");
});
});
+209 -6
View File
@@ -98,11 +98,206 @@ describe('applyStealth — context level', () => {
await page.close();
}
});
test('window.chrome.* ships the rich Layer C shape at runtime', async () => {
const page = await context.newPage();
try {
const shape = await page.evaluate(() => {
const c = (window as any).chrome;
return {
hasRuntime: !!c?.runtime,
hasPlatformArch: !!c?.runtime?.PlatformArch,
hasOnInstalled: !!c?.runtime?.OnInstalledReason,
csiIsFn: typeof c?.csi === 'function',
loadTimesIsFn: typeof c?.loadTimes === 'function',
appIsObj: typeof c?.app === 'object',
};
});
expect(shape.hasRuntime).toBe(true);
expect(shape.hasPlatformArch).toBe(true);
expect(shape.hasOnInstalled).toBe(true);
expect(shape.csiIsFn).toBe(true);
expect(shape.loadTimesIsFn).toBe(true);
expect(shape.appIsObj).toBe(true);
} finally {
await page.close();
}
});
test('Notification.permission is default AND Permissions API returns prompt for notifications', async () => {
// The cdc/Permissions shim now lives in applyStealth, so this pairing
// holds on the plain newContext path too — previously it was headed-only,
// which left Notification.permission=default mismatched against the native
// Permissions answer in headless. Regression guard for that gap.
const page = await context.newPage();
try {
const result = await page.evaluate(async () => {
const perm = typeof Notification !== 'undefined' ? Notification.permission : 'unavailable';
let queryState = 'unavailable';
try {
const status = await navigator.permissions.query({ name: 'notifications' } as any);
queryState = status.state;
} catch {}
return { perm, queryState };
});
expect(result.perm).toBe('default');
expect(result.queryState).toBe('prompt');
} finally {
await page.close();
}
});
test('patched getters report [native code] via the toString proxy', async () => {
const page = await context.newPage();
try {
const getterSrc = await page.evaluate(() => {
const wd = Object.getOwnPropertyDescriptor(navigator, 'webdriver');
return wd && wd.get ? wd.get.toString() : '';
});
// Layer C wraps every patched getter through markNative, so the
// Function.prototype.toString Proxy reports native code instead of the
// injected source — defeats the toString integrity check.
expect(getterSrc).toContain('[native code]');
} finally {
await page.close();
}
});
test('toString proxy survives the depth-3 recursion trick', async () => {
// The headline claim: defeats fn.toString.toString.toString().includes(
// '[native code]'). Depth-1 is covered above; this walks the full chain a
// detector uses so a regression that only masks one level is caught.
const page = await context.newPage();
try {
const depth3 = await page.evaluate(() => {
const wd = Object.getOwnPropertyDescriptor(navigator, 'webdriver');
const get = wd && wd.get;
return get ? (get as any).toString.toString.toString().includes('[native code]') : false;
});
expect(depth3).toBe(true);
} finally {
await page.close();
}
});
test('chrome.csi() and chrome.loadTimes() execute, runtime.connect() throws native-shaped', async () => {
// Presence (typeof === 'function') is not enough — a real detector calls
// them. loadTimes() dereferences performance.timing; connect() must throw
// the native "No matching signature" TypeError.
const page = await context.newPage();
try {
const r = await page.evaluate(() => {
const c = (window as any).chrome;
let connectErr = '';
try { c.runtime.connect(); } catch (e) { connectErr = String(e); }
return {
csiOk: typeof c.csi().onloadT === 'number',
loadTimesOk: typeof c.loadTimes().wasFetchedViaSpdy === 'boolean',
connectErr,
};
});
expect(r.csiOk).toBe(true);
expect(r.loadTimesOk).toBe(true);
expect(r.connectErr).toContain('No matching signature');
} finally {
await page.close();
}
});
});
describe('applyStealth — persistent context (headed-mode parity)', () => {
test('webdriver mask applies to launchPersistentContext too (D7)', async () => {
// Simulate the launchHeaded path: launchPersistentContext + applyStealth
describe('applyStealth — per-install hardware from env', () => {
let ctx: BrowserContext;
let savedHw: string | undefined;
let savedMem: string | undefined;
beforeAll(async () => {
savedHw = process.env.GSTACK_HW_CONCURRENCY;
savedMem = process.env.GSTACK_DEVICE_MEMORY;
process.env.GSTACK_HW_CONCURRENCY = '12';
process.env.GSTACK_DEVICE_MEMORY = '4';
ctx = await browser.newContext();
await applyStealth(ctx); // readHostProfile() reads env at call time
});
afterAll(async () => {
await ctx.close();
if (savedHw === undefined) delete process.env.GSTACK_HW_CONCURRENCY;
else process.env.GSTACK_HW_CONCURRENCY = savedHw;
if (savedMem === undefined) delete process.env.GSTACK_DEVICE_MEMORY;
else process.env.GSTACK_DEVICE_MEMORY = savedMem;
});
test('navigator.hardwareConcurrency and deviceMemory reflect the env profile', async () => {
const page = await ctx.newPage();
try {
const hw = await page.evaluate(() => ({
cores: navigator.hardwareConcurrency,
mem: (navigator as any).deviceMemory,
}));
expect(hw.cores).toBe(12);
expect(hw.mem).toBe(4);
} finally {
await page.close();
}
});
});
describe('applyStealth — extended mode layered on Layer C (GSTACK_STEALTH=extended)', () => {
let ctx: BrowserContext;
let savedStealth: string | undefined;
beforeAll(async () => {
savedStealth = process.env.GSTACK_STEALTH;
process.env.GSTACK_STEALTH = 'extended';
ctx = await browser.newContext();
await applyStealth(ctx);
});
afterAll(async () => {
await ctx.close();
if (savedStealth === undefined) delete process.env.GSTACK_STEALTH;
else process.env.GSTACK_STEALTH = savedStealth;
});
test('extended actually runs: navigator.plugins is the faked PluginArray', async () => {
const page = await ctx.newPage();
try {
const names = await page.evaluate(() => Array.from(navigator.plugins).map((p) => p.name));
expect(names).toContain('PDF Viewer');
} finally {
await page.close();
}
});
test('blend: Layer C wins window.chrome.runtime (rich shape, not extended skeletal)', async () => {
// extended's chrome.runtime is {OnInstalledReason, OnRestartRequiredReason}
// and is if(!)-guarded, so Layer C (applied first, with PlatformArch) must
// win. This pins the coexistence ordering the blend depends on.
const page = await ctx.newPage();
try {
const hasRich = await page.evaluate(() => !!(window as any).chrome?.runtime?.PlatformArch);
expect(hasRich).toBe(true);
} finally {
await page.close();
}
});
test('blend: navigator.webdriver stays false (Layer C own-prop survives extended prototype delete)', async () => {
const page = await ctx.newPage();
try {
const wd = await page.evaluate(() => (navigator as any).webdriver);
expect(wd).toBe(false);
} finally {
await page.close();
}
});
});
describe('applyStealth — persistent context (headed + handoff parity)', () => {
test('full Layer C applies to launchPersistentContext (the launchHeaded/handoff path)', async () => {
// Simulate the launchHeaded/handoff path: launchPersistentContext +
// applyStealth. Verifies the persistent-context path gets the SAME Layer C
// as newContext, not just the webdriver mask.
const fs = await import('fs');
const os = await import('os');
const path = await import('path');
@@ -114,9 +309,17 @@ describe('applyStealth — persistent context (headed-mode parity)', () => {
});
try {
await applyStealth(ctx);
const page = ctx.pages()[0] ?? await ctx.newPage();
const webdriver = await page.evaluate(() => (navigator as any).webdriver);
expect(webdriver).toBe(false);
// Use a page created AFTER applyStealth. launchPersistentContext opens an
// initial page at launch time, before addInitScript is registered, so
// init scripts never run on pages()[0] (its webdriver is only false
// because of the --disable-blink-features launch arg, not Layer C).
const page = await ctx.newPage();
const probe = await page.evaluate(() => ({
webdriver: (navigator as any).webdriver,
hasChromeRuntime: !!(window as any).chrome?.runtime?.PlatformArch,
}));
expect(probe.webdriver).toBe(false);
expect(probe.hasChromeRuntime).toBe(true);
} finally {
await ctx.close();
fs.rmSync(userDataDir, { recursive: true, force: true });