mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 08:07:53 +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:
@@ -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