mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-25 02:59:59 +02:00
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:
@@ -1994,7 +1994,7 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr
|
||||
|
||||
**What:** Write a postinstall script that patches Playwright's CDP layer to suppress `Runtime.enable` and use `addBinding` for context ID discovery, same approach as rebrowser-patches. Eliminates the `navigator.webdriver`, `cdc_` markers, and other CDP artifacts that sites like Google use to detect automation.
|
||||
|
||||
**Why:** Our current stealth narrows to `navigator.webdriver` masking + ChromeDriver `cdc_` runtime cleanup + Permissions API patch (v1.28.0.0 narrowed it from also faking plugins/languages, since modern fingerprinters punish inconsistent fakes more than they punish admitted defaults). That's enough for most sites but Google still triggers captchas, because the real detection is at the CDP protocol level. rebrowser-patches proved the approach works but their patches target Playwright 1.52.0 and don't apply to our 1.58.2. We need our own patcher using string matching instead of line-number diffs. 6 files, ~200 lines of patches total.
|
||||
**Why:** As of v1.58.3.0 our JS-layer stealth is "Layer C" — always-on `navigator.webdriver` mask + `window.chrome.*` shape + `Notification.permission`/Permissions alignment + per-install `hardwareConcurrency`/`deviceMemory` + a `Function.prototype.toString` proxy + an automation-global sweep + ChromeDriver `cdc_`/`__webdriver` cleanup (still NOT faking plugins/languages, since modern fingerprinters punish inconsistent fakes more than they punish admitted defaults). That closes most JS-observable tells, but Google still triggers captchas because the deepest detection is at the CDP protocol level, which a page-world init script can't reach. rebrowser-patches proved the CDP approach works but their patches target Playwright 1.52.0 and don't apply to our 1.58.2. We need our own patcher using string matching instead of line-number diffs. 6 files, ~200 lines of patches total. (Layer C's toString proxy still has descriptor/Reflect.ownKeys surfaces; pushing the spoofs to native code via CDP suppression or the Chromium fork makes the JS layer obsolete.)
|
||||
|
||||
**Context:** Full analysis of rebrowser-patches source: patches 6 files in `playwright-core/lib/server/` (crConnection.js, crDevTools.js, crPage.js, crServiceWorker.js, frames.js, page.js). Key technique: suppress `Runtime.enable` (the main CDP detection vector), use `Runtime.addBinding` + `CustomEvent` trick to discover execution context IDs without it. Our extension communicates via Chrome extension APIs, not CDP Runtime, so it should be unaffected. Write E2E tests that verify: (1) extension still loads and connects, (2) Google.com loads without captcha, (3) sidebar chat still works.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user