Files
gstack/browse/test/stealth-webdriver.test.ts
T
Garry Tan d63f2adb6f 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>
2026-06-18 01:26:48 -07:00

329 lines
12 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { chromium, type Browser, type BrowserContext } from 'playwright';
import { applyStealth, WEBDRIVER_MASK_SCRIPT, STEALTH_LAUNCH_ARGS } from '../src/stealth';
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({ headless: true, args: STEALTH_LAUNCH_ARGS });
});
afterAll(async () => {
await browser.close();
});
describe('STEALTH_LAUNCH_ARGS', () => {
test('includes --disable-blink-features=AutomationControlled', () => {
expect(STEALTH_LAUNCH_ARGS).toContain('--disable-blink-features=AutomationControlled');
});
});
describe('WEBDRIVER_MASK_SCRIPT', () => {
test('contains a single Object.defineProperty for navigator.webdriver', () => {
expect(WEBDRIVER_MASK_SCRIPT).toContain('navigator');
expect(WEBDRIVER_MASK_SCRIPT).toContain('webdriver');
expect(WEBDRIVER_MASK_SCRIPT).toContain('false');
});
test('does NOT touch plugins, languages, or window.chrome (D7 narrowing)', () => {
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/plugins/i);
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/languages/i);
expect(WEBDRIVER_MASK_SCRIPT).not.toMatch(/window\.chrome/);
});
});
describe('applyStealth — context level', () => {
let context: BrowserContext;
beforeAll(async () => {
context = await browser.newContext();
await applyStealth(context);
});
afterAll(async () => {
await context.close();
});
test('navigator.webdriver returns false on a fresh page', async () => {
const page = await context.newPage();
try {
const webdriver = await page.evaluate(() => (navigator as any).webdriver);
expect(webdriver).toBe(false);
} finally {
await page.close();
}
});
test('webdriver is false for every new page in the same context (init script applies to all pages)', async () => {
const p1 = await context.newPage();
const p2 = await context.newPage();
try {
const w1 = await p1.evaluate(() => (navigator as any).webdriver);
const w2 = await p2.evaluate(() => (navigator as any).webdriver);
expect(w1).toBe(false);
expect(w2).toBe(false);
} finally {
await p1.close();
await p2.close();
}
});
test('navigator.plugins is NOT a hardcoded fixed list (D7: let Chromium emit native)', async () => {
const page = await context.newPage();
try {
const plugins = await page.evaluate(() => Array.from(navigator.plugins).map((p) => p.name));
// We do not assert exact contents — Chromium versions vary. We assert
// that we did NOT replace plugins with the wintermute fake list.
// The wintermute approach was: get: () => [1, 2, 3, 4, 5]
const isFake = plugins.length === 5
&& plugins.every((name) => /^[12345]$/.test(String(name)));
expect(isFake).toBe(false);
} finally {
await page.close();
}
});
test('navigator.languages is NOT hardcoded by us (D7)', async () => {
const page = await context.newPage();
try {
const langs = await page.evaluate(() => navigator.languages);
// Whatever Chromium emits is fine; we just assert we are not the
// ones forcing it to ['en-US', 'en'] (wintermute pattern).
// Cannot assert this strictly because Chromium often DOES emit those
// values naturally. Instead, assert that languages is an array of
// strings — i.e. the property still works (we didn't break it).
expect(Array.isArray(langs)).toBe(true);
expect(langs.every((l) => typeof l === 'string')).toBe(true);
} finally {
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 — 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');
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-stealth-'));
const ctx = await chromium.launchPersistentContext(userDataDir, {
headless: true,
args: STEALTH_LAUNCH_ARGS,
});
try {
await applyStealth(ctx);
// 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 });
}
});
});