mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-09 09:38:09 +02:00
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>
This commit is contained in:
@@ -592,48 +592,16 @@ export class BrowserManager {
|
|||||||
this.intentionalDisconnect = false;
|
this.intentionalDisconnect = false;
|
||||||
|
|
||||||
// ─── Anti-bot-detection patches ───────────────────────────────
|
// ─── Anti-bot-detection patches ───────────────────────────────
|
||||||
// D7 (codex correction): mask navigator.webdriver only. We do NOT fake
|
// Apply Layer C stealth (applyStealth): masks navigator.webdriver,
|
||||||
// plugins/languages — modern fingerprinters check consistency between
|
// restores window.chrome.* shape, aligns Notification.permission, sets
|
||||||
// those and userAgent/platform, and synthesizing fixed values can flag
|
// per-install hardware, and strips automation runtime artifacts (cdc_/
|
||||||
// MORE bot-like, not less. Let Chromium's natural plugins and languages
|
// __webdriver globals + the Permissions notifications 'denied' tell).
|
||||||
// surface unmodified.
|
// We still do NOT fake navigator.plugins/languages — faking those flags
|
||||||
//
|
// more bot-like, not less (D7). The cdc/Permissions cleanup moved into
|
||||||
// What we DO clean up are automation-specific runtime artifacts that
|
// applyStealth so headless launch() and handoff() get it too, not just
|
||||||
// shouldn't exist in a real browser at all (Permissions API quirks,
|
// this headed path.
|
||||||
// ChromeDriver-injected window globals). Those aren't fingerprint
|
|
||||||
// synthesis — they're removing leaked automation tells.
|
|
||||||
const { applyStealth } = await import('./stealth');
|
const { applyStealth } = await import('./stealth');
|
||||||
await applyStealth(this.context);
|
await applyStealth(this.context);
|
||||||
await this.context.addInitScript(() => {
|
|
||||||
// Remove CDP runtime artifacts that automation detectors look for
|
|
||||||
// cdc_ prefixed vars are injected by ChromeDriver/CDP
|
|
||||||
const cleanup = () => {
|
|
||||||
for (const key of Object.keys(window)) {
|
|
||||||
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
|
||||||
try {
|
|
||||||
delete (window as any)[key];
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!(e instanceof TypeError)) throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
cleanup();
|
|
||||||
// Re-clean after a tick in case they're injected late
|
|
||||||
setTimeout(cleanup, 0);
|
|
||||||
|
|
||||||
// Override Permissions API to return 'prompt' for notifications
|
|
||||||
// (automation browsers return 'denied' which is a fingerprint)
|
|
||||||
const originalQuery = window.navigator.permissions?.query;
|
|
||||||
if (originalQuery) {
|
|
||||||
(window.navigator.permissions as any).query = (params: any) => {
|
|
||||||
if (params.name === 'notifications') {
|
|
||||||
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
|
|
||||||
}
|
|
||||||
return originalQuery.call(window.navigator.permissions, params);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject visual indicator — subtle top-edge amber gradient
|
// Inject visual indicator — subtle top-edge amber gradient
|
||||||
// Extension's content script handles the floating pill
|
// Extension's content script handles the floating pill
|
||||||
@@ -1619,6 +1587,13 @@ export class BrowserManager {
|
|||||||
this.tabSessions.clear();
|
this.tabSessions.clear();
|
||||||
this.connectionMode = 'headed';
|
this.connectionMode = 'headed';
|
||||||
|
|
||||||
|
// Same Layer C stealth as launch()/launchHeaded(). Must run BEFORE
|
||||||
|
// restoreState() navigates so the init scripts apply to the restored
|
||||||
|
// pages — without this the handed-off browser had cmdline args but no
|
||||||
|
// JS stealth (no webdriver mask, no chrome.* shape, no toString proxy).
|
||||||
|
const { applyStealth } = await import('./stealth');
|
||||||
|
await applyStealth(newContext);
|
||||||
|
|
||||||
if (Object.keys(this.extraHeaders).length > 0) {
|
if (Object.keys(this.extraHeaders).length > 0) {
|
||||||
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-10
@@ -367,17 +367,69 @@ function extendedModeEnabled(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
* Strip automation runtime artifacts that don't belong in a real browser
|
||||||
* Called by browser-manager.launch() and launchHeaded().
|
* and that no fingerprint-synthesis layer can mask: ChromeDriver/CDP window
|
||||||
|
* globals (cdc_*, __webdriver*) and the Permissions API quirk where an
|
||||||
|
* automated Chromium reports notifications as 'denied' instead of real
|
||||||
|
* Chrome's 'prompt'. The 'prompt' answer is aligned with Layer C's
|
||||||
|
* Notification.permission = 'default' so the two surfaces stay consistent.
|
||||||
*
|
*
|
||||||
* Always applies the always-on Layer C stealth script (built from the
|
* Runs on EVERY launch path via applyStealth (headless launch(),
|
||||||
* per-install host profile) — the consistency-first default. When
|
* launchHeaded(), handoff()) so the Notification/Permissions pairing never
|
||||||
* GSTACK_STEALTH=extended is set, layers the opt-in EXTENDED_STEALTH_SCRIPT
|
* diverges by mode. Previously this lived inline in launchHeaded() only,
|
||||||
* on top: its window.chrome.* patches are `if (!...)`-guarded, so Layer C's
|
* which left headless and handoff with the Notification value but not the
|
||||||
* richer shapes win, while the extended-only additions (WebGL spoof, faked
|
* matching Permissions answer.
|
||||||
* navigator.plugins, mediaDevices, cdc_* cleanup) apply on top. Extended
|
*/
|
||||||
* mode actively LIES about the browser and can break sites that reflect on
|
const AUTOMATION_ARTIFACT_CLEANUP_SCRIPT = `(() => {
|
||||||
* these properties, so it stays off by default.
|
// cdc_/__webdriver globals are injected by ChromeDriver/CDP. A detector
|
||||||
|
// finds them by iterating window keys. Strip immediately and again after a
|
||||||
|
// tick in case they are injected late.
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const key of Object.keys(window)) {
|
||||||
|
if (key.startsWith('cdc_') || key.startsWith('__webdriver')) {
|
||||||
|
try { delete window[key]; } catch (e) { if (!(e instanceof TypeError)) throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanup();
|
||||||
|
setTimeout(cleanup, 0);
|
||||||
|
|
||||||
|
// Permissions API: automated Chromium returns 'denied' for notifications,
|
||||||
|
// a known tell. Return 'prompt' to match real Chrome (and Layer C's
|
||||||
|
// Notification.permission = 'default').
|
||||||
|
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
|
||||||
|
if (originalQuery) {
|
||||||
|
window.navigator.permissions.query = (params) => {
|
||||||
|
if (params && params.name === 'notifications') {
|
||||||
|
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||||
|
}
|
||||||
|
return originalQuery.call(window.navigator.permissions, params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply stealth patches to a fresh BrowserContext (or persistent context).
|
||||||
|
* Called by browser-manager.launch(), launchHeaded(), AND handoff() so all
|
||||||
|
* three launch paths get identical stealth.
|
||||||
|
*
|
||||||
|
* Injection order (Playwright evaluates init scripts in registration order):
|
||||||
|
* 1. Layer C (buildStealthScript) — the always-on consistency-first default.
|
||||||
|
* 2. Automation-artifact cleanup (cdc_/__webdriver + Permissions shim),
|
||||||
|
* kept consistent with Layer C's Notification.permission alignment.
|
||||||
|
* 3. EXTENDED_STEALTH_SCRIPT — only when GSTACK_STEALTH=extended (off by
|
||||||
|
* default). Its window.chrome.* patches are `if (!...)`-guarded, so
|
||||||
|
* Layer C's richer shapes win; the extended-only additions (WebGL spoof,
|
||||||
|
* faked navigator.plugins, mediaDevices) apply on top.
|
||||||
|
*
|
||||||
|
* KNOWN LIMITATION (extended mode only, opt-in): extended's functions are NOT
|
||||||
|
* wrapped by Layer C's Function.prototype.toString proxy, so they stringify
|
||||||
|
* as injected code; its prototype-level navigator.webdriver delete is shadowed
|
||||||
|
* by Layer C's own-property getter (net behavior still matches real Chrome:
|
||||||
|
* webdriver present and false); and its hardcoded Apple-M1 WebGL string can
|
||||||
|
* disagree with the env-driven GPU spoof in buildGStackLaunchArgs on non-Apple
|
||||||
|
* hosts. This is acceptable for the documented "actively lies, may break sites"
|
||||||
|
* escape hatch; the consistency-first default (Layer C alone) has none of these.
|
||||||
*
|
*
|
||||||
* Host profile is resolved from process.env at call time so per-install
|
* Host profile is resolved from process.env at call time so per-install
|
||||||
* values bake into the script before Playwright sends it to Chromium via
|
* values bake into the script before Playwright sends it to Chromium via
|
||||||
@@ -386,6 +438,7 @@ function extendedModeEnabled(): boolean {
|
|||||||
export async function applyStealth(context: BrowserContext): Promise<void> {
|
export async function applyStealth(context: BrowserContext): Promise<void> {
|
||||||
const hw = readHostProfile();
|
const hw = readHostProfile();
|
||||||
await context.addInitScript({ content: buildStealthScript(hw) });
|
await context.addInitScript({ content: buildStealthScript(hw) });
|
||||||
|
await context.addInitScript({ content: AUTOMATION_ARTIFACT_CLEANUP_SCRIPT });
|
||||||
if (extendedModeEnabled()) {
|
if (extendedModeEnabled()) {
|
||||||
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
|
await context.addInitScript({ content: EXTENDED_STEALTH_SCRIPT });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,3 +227,30 @@ describe('BrowserManager.onDisconnect exit-code propagation', () => {
|
|||||||
expect(shutdownCalls).toEqual([0, 2, 2]);
|
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 launch path', () => {
|
||||||
|
it('handoff() calls applyStealth and there are >= 3 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');
|
||||||
|
|
||||||
|
// >= 3 total applyStealth call sites (launch, launchHeaded, handoff).
|
||||||
|
const callSites = src.match(/applyStealth\(/g) || [];
|
||||||
|
expect(callSites.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
// The handoff() method body specifically must call applyStealth, before
|
||||||
|
// the resume() JSDoc that follows it.
|
||||||
|
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(');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe('EXTENDED_STEALTH_SCRIPT — six detection-vector patches', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('applyStealth — script wiring', () => {
|
describe('applyStealth — script wiring', () => {
|
||||||
test('default mode applies ONLY the Layer C script (not extended)', async () => {
|
test('default mode applies Layer C + cleanup, not extended', async () => {
|
||||||
delete process.env.GSTACK_STEALTH;
|
delete process.env.GSTACK_STEALTH;
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const fakeCtx = {
|
const fakeCtx = {
|
||||||
@@ -98,14 +98,19 @@ describe('applyStealth — script wiring', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as Parameters<typeof applyStealth>[0];
|
} as unknown as Parameters<typeof applyStealth>[0];
|
||||||
await applyStealth(fakeCtx);
|
await applyStealth(fakeCtx);
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(2);
|
||||||
// Layer C signatures: toString-proxy native-code lie + webdriver mask.
|
// [0] = Layer C (toString-proxy native-code lie + webdriver mask).
|
||||||
expect(calls[0]).toContain('[native code]');
|
expect(calls[0]).toContain('[native code]');
|
||||||
expect(calls[0]).toContain('webdriver');
|
expect(calls[0]).toContain('webdriver');
|
||||||
expect(calls[0]).not.toBe(EXTENDED_STEALTH_SCRIPT);
|
// [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 (Layer C first, extended second)', async () => {
|
test('extended mode applies Layer C, cleanup, then extended (in order)', async () => {
|
||||||
process.env.GSTACK_STEALTH = 'extended';
|
process.env.GSTACK_STEALTH = 'extended';
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const fakeCtx = {
|
const fakeCtx = {
|
||||||
@@ -114,9 +119,9 @@ describe('applyStealth — script wiring', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as Parameters<typeof applyStealth>[0];
|
} as unknown as Parameters<typeof applyStealth>[0];
|
||||||
await applyStealth(fakeCtx);
|
await applyStealth(fakeCtx);
|
||||||
expect(calls).toHaveLength(2);
|
expect(calls).toHaveLength(3);
|
||||||
// Layer C first (its native-code lie), extended second.
|
expect(calls[0]).toContain('[native code]'); // Layer C first
|
||||||
expect(calls[0]).toContain('[native code]');
|
expect(calls[1]).toContain('cdc_'); // cleanup second
|
||||||
expect(calls[1]).toBe(EXTENDED_STEALTH_SCRIPT);
|
expect(calls[2]).toBe(EXTENDED_STEALTH_SCRIPT); // extended last
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user