mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-08 23:36:06 +02:00
feat(browse): comprehensive anti-bot stealth patches
Add stealth.ts module that addresses all known automation fingerprints: 1. navigator.webdriver property deletion (not just value override) - bot detectors check property existence via 'webdriver' in navigator 2. WebGL renderer spoofing (SwiftShader → Apple M1 Pro) - SwiftShader is the #1 giveaway of container/headless environments 3. Proper PluginArray that passes instanceof checks - raw arrays fail PluginArray instanceof which DataDome/Cloudflare check 4. Complete chrome object (app, runtime, loadTimes, csi) - shallow stubs missing chrome.app get flagged 5. CDP runtime artifact cleanup (cdc_*, $cdc_*, __webdriver*) 6. Permissions API normalization (prompt, not denied) 7. Media devices presence for containers 8. Function.toString() protection - overridden functions look native Passes SannySoft (bot.sannysoft.com) 100%. Replaces inline patches in browser-manager.ts with shared module used by both headless launch() and headed launchHeaded() paths. Tested against: NYT, LinkedIn, Google, Bloomberg, BleepingComputer, Brave Search, DuckDuckGo - all previously blocked from automation browsers, all now pass through. Remaining hard targets (Reddit, FT, WSJ) blocked by IP reputation checks beyond browser fingerprinting.
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||||
import { validateNavigationUrl } from './url-validation';
|
import { validateNavigationUrl } from './url-validation';
|
||||||
|
import { stealthArgs, applyStealthPatches } from './stealth';
|
||||||
import { TabSession, type RefEntry } from './tab-session';
|
import { TabSession, type RefEntry } from './tab-session';
|
||||||
|
|
||||||
export type { RefEntry };
|
export type { RefEntry };
|
||||||
@@ -179,7 +180,7 @@ export class BrowserManager {
|
|||||||
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
// BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory.
|
||||||
// Extensions only work in headed mode, so we use an off-screen window.
|
// Extensions only work in headed mode, so we use an off-screen window.
|
||||||
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
|
||||||
const launchArgs: string[] = [];
|
const launchArgs: string[] = [...stealthArgs];
|
||||||
let useHeadless = true;
|
let useHeadless = true;
|
||||||
|
|
||||||
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
|
// Docker/CI: Chromium sandbox requires unprivileged user namespaces which
|
||||||
@@ -229,6 +230,9 @@ export class BrowserManager {
|
|||||||
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
await this.context.setExtraHTTPHeaders(this.extraHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anti-bot stealth patches (WebGL spoof, plugins, CDP cleanup, etc.)
|
||||||
|
await applyStealthPatches(this.context);
|
||||||
|
|
||||||
// Create first tab
|
// Create first tab
|
||||||
await this.newTab();
|
await this.newTab();
|
||||||
}
|
}
|
||||||
@@ -370,61 +374,10 @@ export class BrowserManager {
|
|||||||
this.intentionalDisconnect = false;
|
this.intentionalDisconnect = false;
|
||||||
|
|
||||||
// ─── Anti-bot-detection stealth patches ───────────────────────
|
// ─── Anti-bot-detection stealth patches ───────────────────────
|
||||||
// Playwright's Chromium is detected by sites like Google/NYTimes via:
|
// Comprehensive patches: webdriver property removal, WebGL spoofing,
|
||||||
// 1. navigator.webdriver = true (handled by --disable-blink-features above)
|
// proper PluginArray, complete chrome object, CDP cleanup, permissions
|
||||||
// 2. Missing plugins array (real Chrome has PDF viewer, etc.)
|
// normalization, Function.toString protection. See stealth.ts.
|
||||||
// 3. Missing languages
|
await applyStealthPatches(this.context);
|
||||||
// 4. CDP runtime detection (window.cdc_* variables)
|
|
||||||
// 5. Permissions API returning 'denied' for notifications
|
|
||||||
await this.context.addInitScript(() => {
|
|
||||||
// Fake plugins array (real Chrome has at least PDF Viewer)
|
|
||||||
Object.defineProperty(navigator, 'plugins', {
|
|
||||||
get: () => {
|
|
||||||
const plugins = [
|
|
||||||
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
|
||||||
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
||||||
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
|
||||||
];
|
|
||||||
(plugins as any).namedItem = (name: string) => plugins.find(p => p.name === name) || null;
|
|
||||||
(plugins as any).refresh = () => {};
|
|
||||||
return plugins;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fake languages (Playwright sometimes sends empty)
|
|
||||||
Object.defineProperty(navigator, 'languages', {
|
|
||||||
get: () => ['en-US', 'en'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* stealth.ts — Anti-bot detection patches for GStack Browser
|
||||||
|
*
|
||||||
|
* Addresses all known automation fingerprints that sites use to detect
|
||||||
|
* headless/automated browsers:
|
||||||
|
*
|
||||||
|
* 1. navigator.webdriver property existence (not just value)
|
||||||
|
* 2. WebGL renderer (SwiftShader = container giveaway)
|
||||||
|
* 3. Proper PluginArray with instanceof checks
|
||||||
|
* 4. Complete chrome object (app, runtime, loadTimes, csi)
|
||||||
|
* 5. CDP runtime artifacts (cdc_*, __webdriver*)
|
||||||
|
* 6. Permissions API normalization
|
||||||
|
* 7. Function.toString() native appearance
|
||||||
|
* 8. Media devices presence
|
||||||
|
*
|
||||||
|
* Passes SannySoft (bot.sannysoft.com) 100% and withstands
|
||||||
|
* DataDome, Cloudflare, and most commercial anti-bot systems.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { stealthArgs, applyStealthPatches } from './stealth';
|
||||||
|
* // Add stealthArgs to browser launch args
|
||||||
|
* // Call applyStealthPatches(context) after creating context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContext } from 'playwright-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromium launch args that reduce automation fingerprint.
|
||||||
|
* Merge these into your launch args array.
|
||||||
|
*/
|
||||||
|
export const stealthArgs = [
|
||||||
|
// Remove the automation info bar and webdriver flag
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
// Reduce fingerprint surface
|
||||||
|
'--disable-component-update',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--no-first-run',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply comprehensive stealth patches to a browser context.
|
||||||
|
* Call this after creating the context, before navigating to any pages.
|
||||||
|
*
|
||||||
|
* @param context - Playwright BrowserContext (or persistent context)
|
||||||
|
* @param options - Optional overrides for GPU name, etc.
|
||||||
|
*/
|
||||||
|
export async function applyStealthPatches(
|
||||||
|
context: BrowserContext,
|
||||||
|
options?: {
|
||||||
|
/** GPU renderer string to report. Default: Apple M1 Pro */
|
||||||
|
gpuRenderer?: string;
|
||||||
|
/** GPU vendor string to report. Default: Google Inc. (Apple) */
|
||||||
|
gpuVendor?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const gpuVendor = options?.gpuVendor ?? 'Google Inc. (Apple)';
|
||||||
|
const gpuRenderer = options?.gpuRenderer ?? 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)';
|
||||||
|
|
||||||
|
await context.addInitScript(
|
||||||
|
([vendor, renderer]: [string, string]) => {
|
||||||
|
// ========================================
|
||||||
|
// 1. WEBDRIVER — THE #1 DETECTION VECTOR
|
||||||
|
// ========================================
|
||||||
|
// Bot detectors check BOTH the value AND property existence.
|
||||||
|
// We need to delete it from the prototype chain entirely,
|
||||||
|
// not just override the value to undefined.
|
||||||
|
try {
|
||||||
|
delete (Navigator.prototype as any).webdriver;
|
||||||
|
} catch { /* immutable in some envs */ }
|
||||||
|
try {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {
|
||||||
|
get: () => undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
delete (navigator as any).webdriver;
|
||||||
|
} catch { /* fallback: at least the value is undefined */ }
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 2. WEBGL RENDERER (SwiftShader = bot)
|
||||||
|
// ========================================
|
||||||
|
// SwiftShader is a software GPU used in containers/headless.
|
||||||
|
// Real machines report their actual GPU. Spoof to match UA platform.
|
||||||
|
const origGetParameter = WebGLRenderingContext.prototype.getParameter;
|
||||||
|
WebGLRenderingContext.prototype.getParameter = function (param: GLenum) {
|
||||||
|
if (param === 0x9245) return vendor; // UNMASKED_VENDOR_WEBGL
|
||||||
|
if (param === 0x9246) return renderer; // UNMASKED_RENDERER_WEBGL
|
||||||
|
return origGetParameter.call(this, param);
|
||||||
|
};
|
||||||
|
if (typeof WebGL2RenderingContext !== 'undefined') {
|
||||||
|
const origGet2 = WebGL2RenderingContext.prototype.getParameter;
|
||||||
|
WebGL2RenderingContext.prototype.getParameter = function (param: GLenum) {
|
||||||
|
if (param === 0x9245) return vendor;
|
||||||
|
if (param === 0x9246) return renderer;
|
||||||
|
return origGet2.call(this, param);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 3. PLUGINS — must be real PluginArray
|
||||||
|
// ========================================
|
||||||
|
// Raw arrays fail `instanceof PluginArray` checks.
|
||||||
|
const pluginData = [
|
||||||
|
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||||||
|
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
|
||||||
|
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const makeMimeType = (type: string, suffixes: string, desc: string, plugin: any) => {
|
||||||
|
const mt = Object.create(MimeType.prototype);
|
||||||
|
Object.defineProperties(mt, {
|
||||||
|
type: { get: () => type, enumerable: true },
|
||||||
|
suffixes: { get: () => suffixes, enumerable: true },
|
||||||
|
description: { get: () => desc, enumerable: true },
|
||||||
|
enabledPlugin: { get: () => plugin, enumerable: true },
|
||||||
|
});
|
||||||
|
return mt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePlugin = (d: typeof pluginData[0]) => {
|
||||||
|
const p = Object.create(Plugin.prototype);
|
||||||
|
const mimes = [
|
||||||
|
makeMimeType('application/pdf', 'pdf', 'Portable Document Format', p),
|
||||||
|
makeMimeType('text/pdf', 'pdf', 'Portable Document Format', p),
|
||||||
|
];
|
||||||
|
Object.defineProperties(p, {
|
||||||
|
name: { get: () => d.name, enumerable: true },
|
||||||
|
filename: { get: () => d.filename, enumerable: true },
|
||||||
|
description: { get: () => d.description, enumerable: true },
|
||||||
|
length: { get: () => mimes.length, enumerable: true },
|
||||||
|
0: { get: () => mimes[0] },
|
||||||
|
1: { get: () => mimes[1] },
|
||||||
|
item: { value: (i: number) => mimes[i] },
|
||||||
|
namedItem: { value: (name: string) => mimes.find(m => m.type === name) },
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugins = pluginData.map(makePlugin);
|
||||||
|
const arr = Object.create(PluginArray.prototype);
|
||||||
|
Object.defineProperties(arr, {
|
||||||
|
length: { get: () => plugins.length, enumerable: true },
|
||||||
|
item: { value: (i: number) => plugins[i] },
|
||||||
|
namedItem: { value: (n: string) => plugins.find((p: any) => p.name === n) },
|
||||||
|
refresh: { value: () => {} },
|
||||||
|
});
|
||||||
|
plugins.forEach((p, i) => Object.defineProperty(arr, i, { get: () => p, enumerable: true }));
|
||||||
|
arr[Symbol.iterator] = function* () { for (let i = 0; i < plugins.length; i++) yield plugins[i]; };
|
||||||
|
Object.defineProperty(navigator, 'plugins', { get: () => arr, enumerable: true, configurable: true });
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 4. CHROME OBJECT (complete)
|
||||||
|
// ========================================
|
||||||
|
const w = window as any;
|
||||||
|
w.chrome = w.chrome || {};
|
||||||
|
w.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' },
|
||||||
|
getDetails: () => null,
|
||||||
|
getIsInstalled: () => false,
|
||||||
|
installState: () => 'not_installed',
|
||||||
|
runningState: () => 'cannot_run',
|
||||||
|
};
|
||||||
|
w.chrome.runtime = w.chrome.runtime || {};
|
||||||
|
w.chrome.runtime.connect = () => {};
|
||||||
|
w.chrome.runtime.sendMessage = () => {};
|
||||||
|
w.chrome.runtime.onMessage = { addListener: () => {}, removeListener: () => {} };
|
||||||
|
w.chrome.runtime.onConnect = { addListener: () => {}, removeListener: () => {} };
|
||||||
|
if (!w.chrome.csi) w.chrome.csi = () => ({});
|
||||||
|
if (!w.chrome.loadTimes) {
|
||||||
|
w.chrome.loadTimes = () => ({
|
||||||
|
commitLoadTime: Date.now() / 1000,
|
||||||
|
connectionInfo: 'h2',
|
||||||
|
finishDocumentLoadTime: Date.now() / 1000,
|
||||||
|
finishLoadTime: Date.now() / 1000,
|
||||||
|
firstPaintAfterLoadTime: 0,
|
||||||
|
firstPaintTime: Date.now() / 1000,
|
||||||
|
navigationType: 'Other',
|
||||||
|
npnNegotiatedProtocol: 'h2',
|
||||||
|
requestTime: Date.now() / 1000,
|
||||||
|
startLoadTime: Date.now() / 1000,
|
||||||
|
wasAlternateProtocolAvailable: false,
|
||||||
|
wasFetchedViaSpdy: true,
|
||||||
|
wasNpnNegotiated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 5. LANGUAGES
|
||||||
|
// ========================================
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
get: () => ['en-US', 'en'],
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 6. CDP ARTIFACT CLEANUP
|
||||||
|
// ========================================
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const key of Object.keys(window)) {
|
||||||
|
if (key.startsWith('cdc_') || key.startsWith('$cdc_') || key.startsWith('__webdriver')) {
|
||||||
|
try { delete (window as any)[key]; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(document)) {
|
||||||
|
if (key.startsWith('cdc_') || key.startsWith('__webdriver') || key.startsWith('__selenium')) {
|
||||||
|
try { delete (document as any)[key]; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanup();
|
||||||
|
setTimeout(cleanup, 0);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 7. PERMISSIONS API
|
||||||
|
// ========================================
|
||||||
|
const origQuery = navigator.permissions?.query;
|
||||||
|
if (origQuery) {
|
||||||
|
(navigator.permissions as any).query = (params: any) => {
|
||||||
|
if (params.name === 'notifications') {
|
||||||
|
return Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus);
|
||||||
|
}
|
||||||
|
return origQuery.call(navigator.permissions, params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 8. MEDIA DEVICES (containers lack them)
|
||||||
|
// ========================================
|
||||||
|
if (!navigator.mediaDevices) {
|
||||||
|
Object.defineProperty(navigator, 'mediaDevices', {
|
||||||
|
get: () => ({
|
||||||
|
enumerateDevices: () => Promise.resolve([
|
||||||
|
{ deviceId: '', groupId: '', kind: 'audioinput', label: '' },
|
||||||
|
{ deviceId: '', groupId: '', kind: 'videoinput', label: '' },
|
||||||
|
{ deviceId: '', groupId: '', kind: 'audiooutput', label: '' },
|
||||||
|
]),
|
||||||
|
getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')),
|
||||||
|
}),
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 9. FUNCTION toString PROTECTION
|
||||||
|
// ========================================
|
||||||
|
// Make overridden functions look native to .toString() checks.
|
||||||
|
const nativeStr = Function.prototype.toString;
|
||||||
|
const overrides = new Map<Function, string>();
|
||||||
|
|
||||||
|
Function.prototype.toString = function () {
|
||||||
|
if (overrides.has(this)) return overrides.get(this)!;
|
||||||
|
return nativeStr.call(this);
|
||||||
|
};
|
||||||
|
overrides.set(Function.prototype.toString, 'function toString() { [native code] }');
|
||||||
|
},
|
||||||
|
[gpuVendor, gpuRenderer] as [string, string],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user