mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-02 22:36:00 +02:00
ea961e4faa
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.
128 lines
5.5 KiB
TypeScript
128 lines
5.5 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|