mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
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.
This commit is contained in:
@@ -392,8 +392,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
|
||||
@@ -406,13 +412,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,
|
||||
args: launchArgs,
|
||||
@@ -420,11 +433,7 @@ 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';
|
||||
@@ -1301,15 +1310,16 @@ 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,
|
||||
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) {
|
||||
|
||||
+271
-15
@@ -1,34 +1,266 @@
|
||||
/**
|
||||
* Stealth init script — webdriver-mask only (D7, codex narrowed).
|
||||
* Stealth init script — Layer C of GBrowser's anti-detection plan.
|
||||
*
|
||||
* Modern anti-bot fingerprinters check consistency between navigator
|
||||
* properties (plugins.length, languages, userAgent, platform). Faking those
|
||||
* to fixed values (the wintermute approach) can flag MORE bot-like, not
|
||||
* less, and breaks legitimate sites that reflect on these properties.
|
||||
* D7 (codex correction, kept): we DON'T 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 honest minimum is masking navigator.webdriver, which Chromium exposes
|
||||
* as a known automation tell. Letting plugins/languages/chrome.runtime
|
||||
* surface their native Chromium values keeps the fingerprint internally
|
||||
* consistent.
|
||||
* 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.
|
||||
*
|
||||
* 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 { Browser, BrowserContext } from 'playwright';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
/**
|
||||
* Init script applied to every page in a context. Runs in the page's main
|
||||
* world before any other scripts. Idempotent — defining the same property
|
||||
* twice in different contexts is fine.
|
||||
* 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 {
|
||||
platform: string;
|
||||
hwConcurrency: number;
|
||||
deviceMemory: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
platform: env.GSTACK_PLATFORM || 'MacIntel',
|
||||
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 ────
|
||||
// 25 Selenium globals + Playwright markers + PhantomJS/Nightmare
|
||||
// traces. The inline addInitScript already covers cdc_/__webdriver
|
||||
// dynamic prefixes — this is the static known-name list.
|
||||
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 {}
|
||||
})();`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
||||
* Called by browser-manager.launch() and launchHeaded().
|
||||
*
|
||||
* Resolves the host profile 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();
|
||||
const script = buildStealthScript(hw);
|
||||
await context.addInitScript({ content: 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
|
||||
@@ -37,3 +269,27 @@ export async function applyStealth(context: BrowserContext): Promise<void> {
|
||||
export const STEALTH_LAUNCH_ARGS = [
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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 } from 'bun:test';
|
||||
import {
|
||||
buildStealthScript,
|
||||
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 25 globals + Playwright + Phantom + Nightmare', () => {
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user