mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-29 21:15:37 +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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user