mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-21 06:51:00 +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:
@@ -342,8 +342,8 @@ export class BrowserManager {
|
||||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||||
// Extensions only work in headed mode, so we use an off-screen window.
|
||||
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
||||
const { STEALTH_LAUNCH_ARGS } = await import('./stealth');
|
||||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS];
|
||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
||||
let useHeadless = true;
|
||||
|
||||
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
||||
@@ -407,10 +407,11 @@ export class BrowserManager {
|
||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
// D7: mask navigator.webdriver only. The other 3 wintermute patches
|
||||
// (plugins, languages, chrome.runtime) are intentionally NOT applied —
|
||||
// faking them to fixed values can flag more bot-like to modern
|
||||
// fingerprinters, not less.
|
||||
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
|
||||
// restores window.chrome.* shape, aligns Notification.permission, sets
|
||||
// per-install hardware, and strips automation globals + the Permissions
|
||||
// notifications tell. We still do NOT fake navigator.plugins/languages —
|
||||
// faking those to fixed values flags more bot-like, not less (D7).
|
||||
const { applyStealth } = await import('./stealth');
|
||||
await applyStealth(this.context);
|
||||
|
||||
@@ -436,11 +437,19 @@ export class BrowserManager {
|
||||
|
||||
// Find the gstack extension directory for auto-loading
|
||||
const extensionPath = this.findExtensionPath();
|
||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||
const launchArgs = [
|
||||
'--hide-crash-restore-bubble',
|
||||
// Anti-bot-detection: remove the navigator.webdriver flag that Playwright sets.
|
||||
// Sites like Google and NYTimes check this to block automation browsers.
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
// Anti-bot-detection: --disable-blink-features=AutomationControlled (and any
|
||||
// future blink-level tells) via the shared STEALTH_LAUNCH_ARGS constant — the
|
||||
// same flag launch() and handoff() use, kept in one place instead of a literal.
|
||||
...STEALTH_LAUNCH_ARGS,
|
||||
// GStack Pack 1: per-install hardware/GPU/UA-CH overrides for the
|
||||
// C++ patches in gbrowser's Chromium build. Each switch is a no-op
|
||||
// on Chromium builds without the corresponding patch (the patch's
|
||||
// empty-fallback returns native), so this is safe on stock Playwright
|
||||
// Chromium too.
|
||||
...buildGStackLaunchArgs(),
|
||||
];
|
||||
if (extensionPath) {
|
||||
// Skip --load-extension when running against a custom Chromium build
|
||||
@@ -533,8 +542,14 @@ export class BrowserManager {
|
||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
||||
}
|
||||
|
||||
// Build custom user agent: keep Chrome version for site compatibility,
|
||||
// but replace "Chrome for Testing" branding with "GStackBrowser"
|
||||
// Build custom user agent: report as stock Chrome with the version
|
||||
// matching the underlying Chromium binary. D6 (codex #18 correction):
|
||||
// the previous "GStackBrowser" branding suffix was itself a high-entropy
|
||||
// classifier — sites grepping UA for known browser strings caught us
|
||||
// immediately. Branding still lives in the wrapper .app name + Dock icon
|
||||
// + tray; it does NOT need to be in the UA string for the product to be
|
||||
// "GBrowser." Removing it resolves the "looks like Chrome but identifies
|
||||
// as GStackBrowser" contradiction codex flagged.
|
||||
let customUA: string | undefined;
|
||||
if (!this.customUserAgent) {
|
||||
// Detect Chrome version from the Chromium binary
|
||||
@@ -547,13 +562,20 @@ export class BrowserManager {
|
||||
// Output like: "Google Chrome for Testing 145.0.6422.0" or "Chromium 145.0.6422.0"
|
||||
const versionMatch = versionOutput.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
const chromeVersion = versionMatch ? versionMatch[1] : '131.0.0.0';
|
||||
customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36 GStackBrowser`;
|
||||
customUA = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
|
||||
} catch {
|
||||
// Fallback: generic modern Chrome UA
|
||||
customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 GStackBrowser';
|
||||
customUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
}
|
||||
}
|
||||
|
||||
// T1: strip Playwright's automation-tell defaults. STEALTH_IGNORE_DEFAULT_ARGS
|
||||
// covers the originals (extension-loading blockers) plus --enable-automation
|
||||
// (kills the "Chrome is being controlled by automated test software" infobar
|
||||
// and the chrome-runtime shape changes Playwright otherwise triggers) and
|
||||
// three more (--disable-popup-blocking, --disable-component-update,
|
||||
// --disable-default-apps — each a documented automation tell per Patchright).
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
this.context = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// Match the sandbox policy used by launch() above. Without this,
|
||||
@@ -565,59 +587,23 @@ export class BrowserManager {
|
||||
userAgent: this.customUserAgent || customUA,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
// Playwright adds flags that block extension loading
|
||||
ignoreDefaultArgs: [
|
||||
'--disable-extensions',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
],
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
});
|
||||
this.browser = this.context.browser();
|
||||
this.connectionMode = 'headed';
|
||||
this.intentionalDisconnect = false;
|
||||
|
||||
// ─── Anti-bot-detection patches ───────────────────────────────
|
||||
// D7 (codex correction): mask navigator.webdriver only. We do NOT fake
|
||||
// plugins/languages — modern fingerprinters check consistency between
|
||||
// those and userAgent/platform, and synthesizing fixed values can flag
|
||||
// MORE bot-like, not less. Let Chromium's natural plugins and languages
|
||||
// surface unmodified.
|
||||
//
|
||||
// What we DO clean up are automation-specific runtime artifacts that
|
||||
// shouldn't exist in a real browser at all (Permissions API quirks,
|
||||
// ChromeDriver-injected window globals). Those aren't fingerprint
|
||||
// synthesis — they're removing leaked automation tells.
|
||||
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
|
||||
// restores window.chrome.* shape, aligns Notification.permission, sets
|
||||
// per-install hardware, and strips automation runtime artifacts (cdc_/
|
||||
// __webdriver globals + the Permissions notifications 'denied' tell).
|
||||
// We still do NOT fake navigator.plugins/languages — faking those flags
|
||||
// more bot-like, not less (D7). The cdc/Permissions cleanup moved into
|
||||
// applyStealth so headless launch() and handoff() get it too, not just
|
||||
// this headed path.
|
||||
const { applyStealth } = await import('./stealth');
|
||||
await applyStealth(this.context);
|
||||
await this.context.addInitScript(() => {
|
||||
// Remove CDP runtime artifacts that automation detectors look for
|
||||
// cdc_ prefixed vars are injected by ChromeDriver/CDP
|
||||
const cleanup = () => {
|
||||
for (const key of Object.keys(window)) {
|
||||
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
||||
try {
|
||||
delete (window as any)[key];
|
||||
} catch (e: any) {
|
||||
if (!(e instanceof TypeError)) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
// Re-clean after a tick in case they're injected late
|
||||
setTimeout(cleanup, 0);
|
||||
|
||||
// Override Permissions API to return 'prompt' for notifications
|
||||
// (automation browsers return 'denied' which is a fingerprint)
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
(window.navigator.permissions as any).query = (params: any) => {
|
||||
if (params.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
|
||||
}
|
||||
return originalQuery.call(window.navigator.permissions, params);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Inject visual indicator — subtle top-edge amber gradient
|
||||
// Extension's content script handles the floating pill
|
||||
@@ -1437,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);
|
||||
}
|
||||
@@ -1460,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 {
|
||||
@@ -1556,7 +1553,11 @@ export class BrowserManager {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const extensionPath = this.findExtensionPath();
|
||||
const launchArgs = ['--hide-crash-restore-bubble'];
|
||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||
// Same blink-level stealth flags as launch()/launchHeaded(). Without
|
||||
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
|
||||
// tell that the other two paths strip.
|
||||
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
||||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
@@ -1570,6 +1571,10 @@ export class BrowserManager {
|
||||
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
|
||||
// T1: same automation-tell-stripping defaults as launchHeaded().
|
||||
// The handoff path (headless → headed re-launch) takes the same
|
||||
// anti-detection posture.
|
||||
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
|
||||
newContext = await chromium.launchPersistentContext(userDataDir, {
|
||||
headless: false,
|
||||
// Match the sandbox policy used by launchHeaded() / launch(). The
|
||||
@@ -1579,10 +1584,7 @@ export class BrowserManager {
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||
ignoreDefaultArgs: [
|
||||
'--disable-extensions',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
],
|
||||
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
|
||||
timeout: 15000,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
@@ -1601,6 +1603,13 @@ export class BrowserManager {
|
||||
this.tabSessions.clear();
|
||||
this.connectionMode = 'headed';
|
||||
|
||||
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
||||
// restoreState() navigates so the init scripts apply to the restored
|
||||
// pages — without this the handed-off browser had cmdline args but no
|
||||
// JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
|
||||
const { applyStealth } = await import('./stealth');
|
||||
await applyStealth(newContext);
|
||||
|
||||
if (Object.keys(this.extraHeaders).length > 0) {
|
||||
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
||||
}
|
||||
|
||||
+430
-42
@@ -1,48 +1,252 @@
|
||||
/**
|
||||
* Stealth init scripts — anti-bot detection countermeasures.
|
||||
* Stealth init script — Layer C of GBrowser's anti-detection plan.
|
||||
*
|
||||
* Two modes:
|
||||
* D7 (codex correction, kept): Layer C (the always-on default built by
|
||||
* buildStealthScript) does NOT fake navigator.plugins or
|
||||
* navigator.languages — modern fingerprinters cross-check those against
|
||||
* userAgent / platform / OS, and synthesizing fixed values flags MORE
|
||||
* bot-like, not less. Plugins and languages surface their native
|
||||
* Chromium values. The opt-in EXTENDED_STEALTH_SCRIPT below (gated on
|
||||
* GSTACK_STEALTH=extended, off by default) DOES fake plugins — that mode
|
||||
* is the documented "actively lies, may break sites" escape hatch, not
|
||||
* the default posture.
|
||||
*
|
||||
* 1. DEFAULT (consistency-first, always on): masks navigator.webdriver
|
||||
* and adds --disable-blink-features=AutomationControlled. This is
|
||||
* the original "codex narrowed" minimum that preserves fingerprint
|
||||
* consistency — letting plugins/languages/chrome.runtime surface
|
||||
* native Chromium values keeps the fingerprint internally coherent.
|
||||
* What this script DOES do (the new additions for Phase 1):
|
||||
* 1. Mask navigator.webdriver (the canonical headless tell).
|
||||
* 2. Restore window.chrome.runtime / app / csi / loadTimes — real
|
||||
* Chrome ships them; their absence in headless/automation is a
|
||||
* universally-checked tell. (Vendor research: Cloudflare + DataDome
|
||||
* check chrome.runtime presence + enum shape.)
|
||||
* 3. Align Notification.permission with the Permissions API spoof
|
||||
* that the inline addInitScript already applies — `denied` while
|
||||
* Permissions returns `prompt` is a cross-source inconsistency
|
||||
* detectors flag.
|
||||
* 4. Report per-install hardware values via GSTACK_HW_CONCURRENCY /
|
||||
* GSTACK_DEVICE_MEMORY env vars (set by gbd at startup via
|
||||
* system_profiler + sysctl). Per-install honesty avoids the
|
||||
* cross-user fingerprint cluster a hardcoded default would create.
|
||||
* 5. Install a Function.prototype.toString Proxy that makes every
|
||||
* patched getter report `function ... { [native code] }` at every
|
||||
* recursion depth — defeats the well-known depth-3 detection trick
|
||||
* (`fn.toString.toString.toString().includes('[native code]')`)
|
||||
* that breaks naive stealth tooling.
|
||||
*
|
||||
* 2. EXTENDED (opt-in via GSTACK_STEALTH=extended): six additional
|
||||
* detection-vector patches on top of the default. Closes the
|
||||
* SannySoft test corpus to a 100% pass rate. Originally proposed in
|
||||
* PR #1112 (garrytan, Apr 2026).
|
||||
*
|
||||
* Vectors patched in extended mode:
|
||||
* - navigator.webdriver property fully deleted from prototype
|
||||
* (not just `false` — detectors check `"webdriver" in navigator`)
|
||||
* - WebGL renderer spoofed to a plausible Apple M1 Pro string
|
||||
* (SwiftShader was the #1 software-GPU giveaway in containers)
|
||||
* - navigator.plugins returns a real PluginArray with proper
|
||||
* MimeType objects and namedItem() — `instanceof PluginArray`
|
||||
* passes
|
||||
* - window.chrome populated with chrome.app, chrome.runtime,
|
||||
* chrome.loadTimes(), chrome.csi() with correct shapes
|
||||
* - navigator.mediaDevices present (some headless builds drop it)
|
||||
* - CDP cdc_* property names cleared from window
|
||||
*
|
||||
* Trade-off: extended mode actively LIES about the browser
|
||||
* environment. Sites that reflect on these properties can break or
|
||||
* misbehave. Use only when the default mode triggers detection AND
|
||||
* the target is anti-bot-protected. Not recommended as a global
|
||||
* default.
|
||||
* Codex caveat (acknowledged): a Proxy on Function.prototype.toString
|
||||
* still has detection surfaces (descriptors, Reflect.ownKeys, cross-
|
||||
* realm identity). Phase 2's C++ patches make this layer obsolete by
|
||||
* pushing the spoofs to native code where toString is truly native.
|
||||
* Until then, this is the best JS-only approach.
|
||||
*/
|
||||
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
/**
|
||||
* Always-on default mask: navigator.webdriver returns false. Modern
|
||||
* fingerprinters check the property accessor, so a one-line getter is
|
||||
* sufficient when consistency with the rest of the navigator surface is
|
||||
* preserved.
|
||||
* Host hardware values resolved at browser-manager startup. Values come
|
||||
* from the gbd `host_profile.go` detection (system_profiler + sysctl
|
||||
* on macOS), passed through the GSTACK_* env vars. Each field falls
|
||||
* back to a documented default if the env var is missing or unparseable.
|
||||
*/
|
||||
export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`;
|
||||
interface HostProfile {
|
||||
hwConcurrency: number;
|
||||
deviceMemory: number;
|
||||
}
|
||||
|
||||
// Exported for the clamp/fallback unit test. The platform spoof is owned by
|
||||
// the UA-CH cmdline switch in buildGStackLaunchArgs (which reads GSTACK_PLATFORM
|
||||
// directly), so this profile only carries the values buildStealthScript bakes
|
||||
// into the page-world script.
|
||||
export function readHostProfile(): HostProfile {
|
||||
const env = (globalThis as any).process?.env ?? {};
|
||||
const concurrency = Number(env.GSTACK_HW_CONCURRENCY);
|
||||
const memory = Number(env.GSTACK_DEVICE_MEMORY);
|
||||
return {
|
||||
// Clamp to a plausible default: 0/NaN/negative/missing all fall back to 8.
|
||||
// deviceMemory=0 or NaN would be a glaring bot tell, so never report it.
|
||||
hwConcurrency: Number.isFinite(concurrency) && concurrency > 0 ? concurrency : 8,
|
||||
deviceMemory: Number.isFinite(memory) && memory > 0 ? memory : 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full Layer C stealth init script. The function template-
|
||||
* literal-interpolates the host values so they bake into the script the
|
||||
* page sees — process.env is not accessible from a page-world init script,
|
||||
* so values must be resolved by the browser-manager process before
|
||||
* injection.
|
||||
*
|
||||
* The script is one big self-invoking function so all the patches
|
||||
* happen atomically before any page code runs. Order matters: the
|
||||
* Function.prototype.toString Proxy installs FIRST so all subsequent
|
||||
* defineProperty getters are covered by its native-code lie.
|
||||
*/
|
||||
export function buildStealthScript(hw: HostProfile): string {
|
||||
return `(() => {
|
||||
// ──── Function.prototype.toString Proxy (must run first) ────
|
||||
// Make every patched getter / function below report
|
||||
// 'function NAME() { [native code] }' at every recursion depth.
|
||||
// Defeats fn.toString.toString.toString() integrity checks.
|
||||
const patchedFns = new WeakSet();
|
||||
const nativeToString = Function.prototype.toString;
|
||||
const toStringProxy = new Proxy(nativeToString, {
|
||||
apply(target, thisArg, args) {
|
||||
if (patchedFns.has(thisArg)) {
|
||||
const name = (thisArg && thisArg.name) || '';
|
||||
return 'function ' + name + '() { [native code] }';
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(Function.prototype, 'toString', {
|
||||
value: toStringProxy, writable: true, configurable: true,
|
||||
});
|
||||
const markNative = (fn, name) => {
|
||||
if (name) {
|
||||
try { Object.defineProperty(fn, 'name', { value: name }); } catch {}
|
||||
}
|
||||
patchedFns.add(fn);
|
||||
return fn;
|
||||
};
|
||||
|
||||
// ──── navigator.webdriver (canonical mask, kept from D7) ────
|
||||
try {
|
||||
const webdriverGetter = markNative(function() { return false; }, 'get webdriver');
|
||||
Object.defineProperty(navigator, 'webdriver', { get: webdriverGetter, configurable: true });
|
||||
} catch {}
|
||||
|
||||
// ──── window.chrome.* restoration ────
|
||||
// Real Chrome ships these objects with rich enum / method shape.
|
||||
// Headless Chromium / Playwright's launch strips them. Their absence
|
||||
// is a universally-checked tell (verified in Cloudflare + DataDome
|
||||
// RE catalogs). We don't try to perfectly mimic — we ship plausible
|
||||
// shape with native-code-looking methods.
|
||||
try {
|
||||
if (!('chrome' in window)) {
|
||||
window.chrome = {};
|
||||
}
|
||||
const chrome = window.chrome;
|
||||
if (!chrome.runtime) {
|
||||
chrome.runtime = {
|
||||
OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install',
|
||||
SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' },
|
||||
OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' },
|
||||
PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64',
|
||||
X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64',
|
||||
X86_32: 'x86-32', X86_64: 'x86-64' },
|
||||
PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux',
|
||||
MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' },
|
||||
RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled',
|
||||
UPDATE_AVAILABLE: 'update_available' },
|
||||
connect: markNative(function connect() {
|
||||
throw new TypeError('Error in invocation of runtime.connect: No matching signature.');
|
||||
}, 'connect'),
|
||||
sendMessage: markNative(function sendMessage() {
|
||||
throw new TypeError('Error in invocation of runtime.sendMessage: No matching signature.');
|
||||
}, 'sendMessage'),
|
||||
id: undefined,
|
||||
};
|
||||
}
|
||||
if (!chrome.app) {
|
||||
chrome.app = {
|
||||
isInstalled: false,
|
||||
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
||||
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
||||
};
|
||||
}
|
||||
if (typeof chrome.csi !== 'function') {
|
||||
chrome.csi = markNative(function csi() {
|
||||
return {
|
||||
onloadT: Date.now(),
|
||||
pageT: performance.now(),
|
||||
startE: Date.now() - 1000,
|
||||
tran: 15,
|
||||
};
|
||||
}, 'csi');
|
||||
}
|
||||
if (typeof chrome.loadTimes !== 'function') {
|
||||
chrome.loadTimes = markNative(function loadTimes() {
|
||||
const t = performance.timing;
|
||||
return {
|
||||
requestTime: t.requestStart / 1000,
|
||||
startLoadTime: t.requestStart / 1000,
|
||||
commitLoadTime: t.responseStart / 1000,
|
||||
finishDocumentLoadTime: t.domContentLoadedEventEnd / 1000,
|
||||
finishLoadTime: t.loadEventEnd / 1000,
|
||||
firstPaintTime: t.responseEnd / 1000,
|
||||
firstPaintAfterLoadTime: 0,
|
||||
navigationType: 'Other',
|
||||
wasFetchedViaSpdy: true,
|
||||
wasNpnNegotiated: true,
|
||||
npnNegotiatedProtocol: 'h2',
|
||||
wasAlternateProtocolAvailable: false,
|
||||
connectionInfo: 'h2',
|
||||
};
|
||||
}, 'loadTimes');
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal — page might have a stricter Content Security Policy
|
||||
// that blocks property mutation on window. Leave chrome.* whatever
|
||||
// shape it was; navigator.webdriver mask still applies.
|
||||
}
|
||||
|
||||
// ──── Notification.permission align with Permissions API ────
|
||||
// The inline addInitScript already overrides permissions.query for
|
||||
// notifications → 'prompt'. Notification.permission must match
|
||||
// ('default' in real Chrome on pages that haven't asked yet).
|
||||
try {
|
||||
if (typeof Notification !== 'undefined') {
|
||||
const notificationPermissionGetter = markNative(function() { return 'default'; }, 'get permission');
|
||||
Object.defineProperty(Notification, 'permission', {
|
||||
get: notificationPermissionGetter,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// ──── Per-install hardware values from GSTACK_* env (T2) ────
|
||||
// gbd's host_profile.go fed real host values via cmdline env. Reporting
|
||||
// those (not hardcoded defaults) avoids the cross-user GBrowser
|
||||
// fingerprint cluster.
|
||||
try {
|
||||
const hwConcurrencyGetter = markNative(function() { return ${hw.hwConcurrency}; }, 'get hardwareConcurrency');
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', {
|
||||
get: hwConcurrencyGetter,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
const deviceMemoryGetter = markNative(function() { return ${hw.deviceMemory}; }, 'get deviceMemory');
|
||||
Object.defineProperty(navigator, 'deviceMemory', {
|
||||
get: deviceMemoryGetter,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
// ──── Selenium / Phantom / Nightmare / Playwright global cleanup ────
|
||||
// Static known-name list of Selenium / Playwright / PhantomJS / Nightmare
|
||||
// globals. AUTOMATION_ARTIFACT_CLEANUP_SCRIPT (applied right after this on
|
||||
// every path) covers the cdc_/__webdriver dynamic prefixes; this list is the
|
||||
// fixed-name complement.
|
||||
try {
|
||||
const auto = [
|
||||
'__driver_evaluate', '__webdriver_evaluate', '__selenium_evaluate', '__fxdriver_evaluate',
|
||||
'__driver_unwrapped', '__webdriver_unwrapped', '__selenium_unwrapped', '__fxdriver_unwrapped',
|
||||
'_Selenium_IDE_Recorder', '_selenium', 'calledSelenium',
|
||||
'$chrome_asyncScriptInfo',
|
||||
'__$webdriverAsyncExecutor', '__webdriverFunc',
|
||||
'domAutomation', 'domAutomationController',
|
||||
'__lastWatirAlert', '__lastWatirConfirm', '__lastWatirPrompt',
|
||||
'__webdriver_script_fn', '_WEBDRIVER_ELEM_CACHE',
|
||||
'callPhantom', '_phantom', 'phantom', '__nightmare',
|
||||
'__pwInitScripts', '__playwright__binding__',
|
||||
];
|
||||
for (const k of auto) {
|
||||
try { delete window[k]; } catch {}
|
||||
}
|
||||
try { delete document.__webdriver_script_fn; } catch {}
|
||||
} catch {}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended-mode init script — six detection-vector patches. Applied
|
||||
@@ -157,6 +361,9 @@ export const EXTENDED_STEALTH_SCRIPT = `
|
||||
// 6. CDP cdc_* property cleanup. Chromium under CDP sets cdc_*-prefixed
|
||||
// globals (driver injection markers); a bot detector finds them by
|
||||
// iterating window keys. Strip all matching keys.
|
||||
// Note: via applyStealth this is redundant with AUTOMATION_ARTIFACT_
|
||||
// CLEANUP_SCRIPT (which runs first on every path). Kept so this script
|
||||
// is self-sufficient if ever applied standalone.
|
||||
for (const k of Object.keys(window)) {
|
||||
if (k.startsWith('cdc_')) {
|
||||
try { delete window[k]; } catch {}
|
||||
@@ -172,28 +379,209 @@ function extendedModeEnabled(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stealth patches to a fresh BrowserContext (or persistent
|
||||
* context). Called by browser-manager.launch() and launchHeaded().
|
||||
* Always applies the WEBDRIVER_MASK_SCRIPT; only applies the
|
||||
* EXTENDED_STEALTH_SCRIPT when GSTACK_STEALTH=extended.
|
||||
* Strip automation runtime artifacts that don't belong in a real browser
|
||||
* and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window
|
||||
* globals (cdc_*, __webdriver*) and the Permissions API quirk where an
|
||||
* automated Chromium reports notifications as 'denied' instead of real
|
||||
* Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's
|
||||
* Notification.permission = 'default' so the two surfaces stay consistent.
|
||||
*
|
||||
* Runs on EVERY launch path via applyStealth (headless launch(),
|
||||
* launchHeaded(), handoff()) so the Notification/Permissions pairing never
|
||||
* diverges by mode. Previously this lived inline in launchHeaded() only,
|
||||
* which left headless and handoff with the Notification value but not the
|
||||
* matching Permissions answer.
|
||||
*/
|
||||
export const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
|
||||
// cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector
|
||||
// finds them by iterating window keys. Strip immediately and again after a
|
||||
// tick in case they are injected late.
|
||||
const cleanup = () => {
|
||||
for (const key of Object.keys(window)) {
|
||||
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
||||
try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; }
|
||||
}
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
setTimeout(cleanup, 0);
|
||||
|
||||
// Permissions API: automated Chromium returns 'denied' for notifications,
|
||||
// a known tell. Return 'prompt' to match real Chrome (and Layer C's
|
||||
// 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) => {
|
||||
if (params && params.name === 'notifications') {
|
||||
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||
}
|
||||
return originalQuery.call(window.navigator.permissions, params);
|
||||
};
|
||||
}
|
||||
})();`;
|
||||
|
||||
/**
|
||||
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
||||
* 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.
|
||||
* 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim),
|
||||
* kept consistent with Layer C's Notification.permission alignment.
|
||||
* 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by
|
||||
* default). Its window.chrome.* patches are `if (!...)`-guarded, so
|
||||
* Layer C's richer shapes win; the extended-only additions (WebGL spoof,
|
||||
* faked navigator.plugins, mediaDevices) apply on top.
|
||||
*
|
||||
* KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT
|
||||
* wrapped by Layer C's Function.prototype.toString proxy, so they stringify
|
||||
* as injected code; its prototype-level navigator.webdriver delete is shadowed
|
||||
* by Layer C's own-property getter (net behavior still matches real Chrome:
|
||||
* webdriver present and false); and its hardcoded Apple-M1 WebGL string can
|
||||
* disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple
|
||||
* hosts. This is acceptable for the documented "actively lies, may break sites"
|
||||
* escape hatch; the consistency-first default (Layer C alone) has none of these.
|
||||
*
|
||||
* Host profile is resolved from process.env at call time so per-install
|
||||
* values bake into the script before Playwright sends it to Chromium via
|
||||
* Page.addScriptToEvaluateOnNewDocument.
|
||||
*/
|
||||
export async function applyStealth(context: BrowserContext): Promise<void> {
|
||||
await context.addInitScript({ content: WEBDRIVER_MASK_SCRIPT });
|
||||
const hw = readHostProfile();
|
||||
await context.addInitScript({ content: buildStealthScript(hw) });
|
||||
await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT });
|
||||
if (extendedModeEnabled()) {
|
||||
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy single-line webdriver mask, exported for backwards
|
||||
* compatibility with any caller that uses it directly. New callers
|
||||
* should use applyStealth() which includes this plus the Layer C
|
||||
* additions.
|
||||
*/
|
||||
export const WEBDRIVER_MASK_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => false });`;
|
||||
|
||||
/**
|
||||
* Args added to chromium.launch's `args` to suppress the
|
||||
* AutomationControlled blink feature. This is independent of the init
|
||||
* script — it changes how Chromium identifies itself in the protocol
|
||||
* layer.
|
||||
* script — it changes how Chromium identifies itself in the protocol layer.
|
||||
*/
|
||||
export const STEALTH_LAUNCH_ARGS = [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the `--gstack-*=` cmdline switches that the Pack 1 Chromium
|
||||
* patches read (webgl-vendor-spoof, ua-client-hints-stealth, worker-
|
||||
* navigator-stealth). Values come from the GSTACK_* env vars that
|
||||
* gbd populates from host_profile.go at startup.
|
||||
*
|
||||
* Each switch is only emitted when its env var is non-empty — empty
|
||||
* env values fall through to the patch's "no override" path, which
|
||||
* returns the real Chromium native value. This keeps the helper safe
|
||||
* on builds that DO NOT have the C++ patches applied (gbrowser
|
||||
* 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
|
||||
* GSTACK_PLATFORM → --gstack-ua-platform → ua-client-hints-stealth.patch
|
||||
* (maps MacARM/MacIntel → "macOS")
|
||||
* GSTACK_GPU_CHIPSET → --gstack-ua-model → ua-client-hints-stealth.patch
|
||||
* GSTACK_HW_CONCURRENCY → --gstack-hw-concurrency → worker-navigator-stealth.patch
|
||||
* GSTACK_DEVICE_MEMORY → --gstack-device-memory → worker-navigator-stealth.patch
|
||||
*/
|
||||
export function buildGStackLaunchArgs(): string[] {
|
||||
const env = (globalThis as any).process?.env ?? {};
|
||||
const args: string[] = [];
|
||||
|
||||
const vendor = env.GSTACK_GPU_VENDOR;
|
||||
if (vendor) args.push(`--gstack-gpu-vendor=${vendor}`);
|
||||
|
||||
const renderer = env.GSTACK_GPU_RENDERER;
|
||||
if (renderer) args.push(`--gstack-gpu-renderer=${renderer}`);
|
||||
|
||||
// Map gbd's "MacARM"/"MacIntel" classification to the UA-CH "macOS"
|
||||
// platform string Chromium emits natively. Other future platforms
|
||||
// would map similarly (Win32 → "Windows", Linux → "Linux").
|
||||
const platform = env.GSTACK_PLATFORM;
|
||||
if (platform === 'MacARM' || platform === 'MacIntel') {
|
||||
args.push('--gstack-ua-platform=macOS');
|
||||
} else if (platform === 'Win32') {
|
||||
args.push('--gstack-ua-platform=Windows');
|
||||
} else if (platform && platform.startsWith('Linux')) {
|
||||
args.push('--gstack-ua-platform=Linux');
|
||||
}
|
||||
|
||||
const chipset = env.GSTACK_GPU_CHIPSET;
|
||||
if (chipset) args.push(`--gstack-ua-model=${chipset}`);
|
||||
|
||||
const hw = env.GSTACK_HW_CONCURRENCY;
|
||||
if (hw) args.push(`--gstack-hw-concurrency=${hw}`);
|
||||
|
||||
const memory = env.GSTACK_DEVICE_MEMORY;
|
||||
if (memory) args.push(`--gstack-device-memory=${memory}`);
|
||||
|
||||
// Pack 2 / B11: suppress user-defined Error.prepareStackTrace during
|
||||
// V8 stack-trace formatting. Closes the Cloudflare Bot Management canary
|
||||
// trick where a page sets prepareStackTrace and watches for it to fire
|
||||
// during CDP serialization.
|
||||
//
|
||||
// OPT-IN (off by default): only emitted when GSTACK_CDP_STEALTH is
|
||||
// on/1/true. This switch is read by a C++ patch that only exists in
|
||||
// gbrowser builds; gbd opts in by exporting GSTACK_CDP_STEALTH=on. Stock
|
||||
// Playwright Chromium leaves it unset, so the flag never reaches a
|
||||
// Chromium that wouldn't understand it. (Previously this was on-by-default
|
||||
// unless GSTACK_CDP_STEALTH=off, which contradicted this very comment.)
|
||||
const cdpStealth = env.GSTACK_CDP_STEALTH;
|
||||
if (cdpStealth === 'on' || cdpStealth === '1' || cdpStealth === 'true') {
|
||||
args.push('--gstack-suppress-prepare-stack-trace');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright default args to strip via ignoreDefaultArgs.
|
||||
*
|
||||
* Playwright passes these by default. Each one is a visible automation
|
||||
* tell at some layer:
|
||||
* --enable-automation → infobar + chrome shape
|
||||
* --disable-extensions → blocks our extension
|
||||
* --disable-component-extensions-with-background-pages → blocks component ext
|
||||
* --disable-popup-blocking → automation default
|
||||
* --disable-component-update → automation default
|
||||
* --disable-default-apps → affects plugin enum
|
||||
*
|
||||
* Used by browser-manager via spread into ignoreDefaultArgs to keep
|
||||
* the list in one place across launchHeaded() and handoff().
|
||||
*/
|
||||
export const STEALTH_IGNORE_DEFAULT_ARGS = [
|
||||
'--enable-automation',
|
||||
'--disable-extensions',
|
||||
'--disable-component-extensions-with-background-pages',
|
||||
'--disable-popup-blocking',
|
||||
'--disable-component-update',
|
||||
'--disable-default-apps',
|
||||
];
|
||||
|
||||
/** Test-only helper: report whether extended mode is currently active. */
|
||||
export function isExtendedStealthEnabled(): boolean {
|
||||
return extendedModeEnabled();
|
||||
|
||||
Reference in New Issue
Block a user