diff --git a/src/stealth.test.ts b/src/stealth.test.ts index 8cebf69..a2efcd6 100644 --- a/src/stealth.test.ts +++ b/src/stealth.test.ts @@ -106,6 +106,8 @@ describe('Stealth mode', () => { hasCanShare: typeof navigator.canShare === 'function', hasConnectionDownlinkMax: !!navigator.connection && typeof navigator.connection.downlinkMax === 'number', + hasConnectionDownlinkMaxOnProto: + !!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection), })); expect(signals.mimeTypesLength).toBeGreaterThan(0); @@ -113,5 +115,59 @@ describe('Stealth mode', () => { expect(signals.hasShare).toBe(true); expect(signals.hasCanShare).toBe(true); expect(signals.hasConnectionDownlinkMax).toBe(true); + expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true); + }); + + it('exposes contacts manager and content index APIs', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const signals = await browser.getPage().evaluate(() => ({ + hasContacts: 'contacts' in navigator, + contactsManagerCtor: typeof (window as any).ContactsManager === 'function', + hasContentIndexCtor: typeof (window as any).ContentIndex === 'function', + hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined', + hasContentIndexOnSWR: + typeof ServiceWorkerRegistration !== 'undefined' && + ('contentIndex' in ServiceWorkerRegistration.prototype || + 'index' in ServiceWorkerRegistration.prototype), + notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null, + screenMatchesViewport: + screen.width === window.innerWidth && screen.height === window.innerHeight, + })); + + expect(signals.hasContacts).toBe(true); + expect(signals.contactsManagerCtor).toBe(true); + expect(signals.hasContentIndexCtor).toBe(true); + if (signals.hasServiceWorkerRegistration) { + expect(signals.hasContentIndexOnSWR).toBe(true); + } + expect(signals.notificationPermission).toBe('default'); + expect(signals.screenMatchesViewport).toBe(false); + }); + + it('exposes downlinkMax inside dedicated workers', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const workerSignals = await browser.getPage().evaluate(async () => { + return new Promise<{ + hasConnection: boolean; + hasDownlinkMax: boolean; + hasDownlinkMaxOnProto: boolean; + downlinkMax: unknown; + }>((resolve) => { + const source = + "postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});"; + const blob = new Blob([source], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + worker.onmessage = (event) => resolve(event.data); + }); + }); + + expect(workerSignals.hasConnection).toBe(true); + expect(workerSignals.hasDownlinkMax).toBe(true); + expect(workerSignals.hasDownlinkMaxOnProto).toBe(true); + expect(typeof workerSignals.downlinkMax).toBe('number'); }); }); diff --git a/src/stealth.ts b/src/stealth.ts index 418abda..87cb05d 100644 --- a/src/stealth.ts +++ b/src/stealth.ts @@ -6,7 +6,7 @@ * Puppeteer / headless Chrome. */ -import type { BrowserContext } from 'playwright-core'; +import type { BrowserContext, Page } from 'playwright-core'; export interface StealthScriptOptions { locale?: string; @@ -27,6 +27,80 @@ export async function applyStealthScripts( options: StealthScriptOptions = {} ): Promise { await context.addInitScript({ content: buildStealthScript(options) }); + + // Apply CDP-level User-Agent override so Workers also get the patched UA. + // This must be done per-page since CDP sessions are page-scoped. + for (const page of context.pages()) { + await applyCDPStealthToPage(page); + } + context.on('page', (page: Page) => applyCDPStealthToPage(page)); +} + +async function applyCDPStealthToPage(page: Page): Promise { + try { + const cdp = await page.context().newCDPSession(page); + const ua = await cdp.send('Browser.getVersion').catch(() => null); + const rawUA = ua?.userAgent ?? ''; + const patchedUA = rawUA.replace(/HeadlessChrome/g, 'Chrome'); + + const versionMatch = patchedUA.match(/Chrome\/(\d+)/); + const majorVersion = versionMatch?.[1] ?? '120'; + const fullVersionMatch = patchedUA.match(/Chrome\/([\d.]+)/); + const fullVersion = fullVersionMatch?.[1] ?? `${majorVersion}.0.0.0`; + + await cdp.send('Emulation.setUserAgentOverride', { + userAgent: patchedUA, + acceptLanguage: 'en-US,en;q=0.9', + platform: getPlatformString(), + userAgentMetadata: { + brands: [ + { brand: 'Chromium', version: majorVersion }, + { brand: 'Google Chrome', version: majorVersion }, + { brand: 'Not-A.Brand', version: '99' }, + ], + fullVersionList: [ + { brand: 'Chromium', version: fullVersion }, + { brand: 'Google Chrome', version: fullVersion }, + { brand: 'Not-A.Brand', version: '99.0.0.0' }, + ], + fullVersion: fullVersion, + platform: getPlatformHint(), + platformVersion: getPlatformVersionHint(), + architecture: 'x86', + model: '', + mobile: false, + bitness: '64', + wow64: false, + }, + }); + await cdp.detach(); + } catch { + // CDP not available (non-Chromium) -- silently skip + } +} + +function getPlatformString(): string { + if (typeof process !== 'undefined') { + if (process.platform === 'darwin') return 'macOS'; + if (process.platform === 'win32') return 'Windows'; + } + return 'Linux'; +} + +function getPlatformHint(): string { + if (typeof process !== 'undefined') { + if (process.platform === 'darwin') return 'macOS'; + if (process.platform === 'win32') return 'Windows'; + } + return 'Linux'; +} + +function getPlatformVersionHint(): string { + if (typeof process !== 'undefined') { + if (process.platform === 'darwin') return '13.0.0'; + if (process.platform === 'win32') return '10.0.0'; + } + return '6.1.0'; } function normalizeLocale(locale?: string): string | undefined { @@ -65,11 +139,15 @@ function buildStealthScript(options: StealthScriptOptions): string { patchWebGLVendor(), patchCdcProperties(), patchWindowDimensions(), + patchScreenDimensions(), patchScreenAvailability(), patchNavigatorHardwareConcurrency(), + patchNotificationPermission(), patchNavigatorConnection(), + patchWorkerConnection(), patchNavigatorShare(), patchNavigatorContacts(), + patchContentIndex(), patchPdfViewerEnabled(), patchMediaDevices(), patchUserAgentData(), @@ -432,6 +510,37 @@ function patchScreenAvailability(): string { })();`; } +/** + * Avoid screen == viewport fingerprints common in headless defaults. + */ +function patchScreenDimensions(): string { + return `(function(){ + const patchNumber = (target, key, value) => { + try { + Object.defineProperty(target, key, { + get: () => value, + configurable: true, + }); + } catch {} + }; + const width = Number(screen.width); + const height = Number(screen.height); + const innerWidth = Number(window.innerWidth); + const innerHeight = Number(window.innerHeight); + if ( + Number.isFinite(width) && + Number.isFinite(height) && + Number.isFinite(innerWidth) && + Number.isFinite(innerHeight) && + width === innerWidth && + height === innerHeight + ) { + patchNumber(screen, 'width', Math.max(innerWidth + 86, 1366)); + patchNumber(screen, 'height', Math.max(innerHeight + 48, 768)); + } +})();`; +} + /** * navigator.hardwareConcurrency: headless often reports 2 (CI); * real desktops typically have >= 4 cores. @@ -447,6 +556,23 @@ function patchNavigatorHardwareConcurrency(): string { })();`; } +/** + * Keep notifications in "default" state instead of denied-by-default headless behavior. + */ +function patchNotificationPermission(): string { + return `(function(){ + if (typeof Notification === 'undefined') return; + const current = Notification.permission; + if (current === 'granted') return; + try { + Object.defineProperty(Notification, 'permission', { + get: () => 'default', + configurable: true, + }); + } catch {} +})();`; +} + /** * Add missing connection.downlinkMax in Chromium headless environments. */ @@ -455,12 +581,103 @@ function patchNavigatorConnection(): string { if (!navigator.connection) return; const conn = navigator.connection; if (typeof conn.downlinkMax === 'number') return; + const defineDownlinkMax = (target) => { + if (!target) return false; + try { + Object.defineProperty(target, 'downlinkMax', { + get: () => 10, + configurable: true, + }); + return true; + } catch { + return false; + } + }; try { - Object.defineProperty(conn, 'downlinkMax', { - get: () => 10, + const proto = Object.getPrototypeOf(conn); + if (defineDownlinkMax(proto)) { + try { delete conn.downlinkMax; } catch {} + return; + } + } catch {} + defineDownlinkMax(conn); +})();`; +} + +/** + * Ensure dedicated workers expose navigator.connection.downlinkMax too. + */ +function patchWorkerConnection(): string { + return `(function(){ + if (typeof Worker !== 'function') return; + const NativeWorker = Worker; + const workerPrelude = \` +(() => { + try { + if (!navigator || !navigator.connection) return; + const conn = navigator.connection; + if (typeof conn.downlinkMax === 'number') return; + const defineDownlinkMax = (target) => { + if (!target) return false; + try { + Object.defineProperty(target, 'downlinkMax', { + get: () => 10, + configurable: true, + }); + return true; + } catch { + return false; + } + }; + try { + const proto = Object.getPrototypeOf(conn); + if (defineDownlinkMax(proto)) { + try { delete conn.downlinkMax; } catch {} + return; + } + } catch {} + defineDownlinkMax(conn); + } catch {} +})(); +\`; + const buildPatchedScript = (url, options) => { + const scriptUrl = String(url); + const isModule = options && options.type === 'module'; + const loader = isModule + ? \`import \${JSON.stringify(scriptUrl)};\` + : \`importScripts(\${JSON.stringify(scriptUrl)});\`; + return \`\${workerPrelude}\\n\${loader}\`; + }; + const WrappedWorker = function(scriptURL, options) { + try { + const source = buildPatchedScript(scriptURL, options); + const blob = new Blob([source], { type: 'application/javascript' }); + const patchedUrl = URL.createObjectURL(blob); + return new NativeWorker(patchedUrl, options); + } catch { + return new NativeWorker(scriptURL, options); + } + }; + WrappedWorker.prototype = NativeWorker.prototype; + try { + Object.setPrototypeOf(WrappedWorker, NativeWorker); + } catch {} + try { + Object.defineProperty(WrappedWorker, 'name', { value: 'Worker', configurable: true }); + } catch {} + try { + Object.defineProperty(WrappedWorker, 'toString', { + value: () => NativeWorker.toString(), configurable: true, }); } catch {} + try { + Object.defineProperty(window, 'Worker', { + value: WrappedWorker, + configurable: true, + writable: true, + }); + } catch {} })();`; } @@ -493,16 +710,83 @@ function patchNavigatorShare(): string { */ function patchNavigatorContacts(): string { return `(function(){ - if (navigator.contacts) return; + const ContactsCtor = typeof ContactsManager === 'function' + ? ContactsManager + : function ContactsManager() {}; try { - Object.defineProperty(navigator, 'contacts', { - value: { - select: async () => [], - getProperties: () => ['name', 'email', 'tel', 'address', 'icon'], - }, + Object.defineProperty(window, 'ContactsManager', { + value: ContactsCtor, configurable: true, }); } catch {} + const manager = Object.create(ContactsCtor.prototype || Object.prototype); + if (typeof manager.select !== 'function') { + manager.select = async () => []; + } + if (typeof manager.getProperties !== 'function') { + manager.getProperties = () => ['name', 'email', 'tel', 'address', 'icon']; + } + const defineContacts = (target) => { + if (!target) return false; + try { + Object.defineProperty(target, 'contacts', { + get: () => manager, + configurable: true, + }); + return true; + } catch { + return false; + } + }; + if (defineContacts(navigator)) return; + try { + defineContacts(Object.getPrototypeOf(navigator)); + } catch {} +})();`; +} + +/** + * Expose ContentIndex APIs expected on modern Chromium. + */ +function patchContentIndex(): string { + return `(function(){ + const ContentIndexCtor = typeof ContentIndex === 'function' + ? ContentIndex + : function ContentIndex() {}; + try { + Object.defineProperty(window, 'ContentIndex', { + value: ContentIndexCtor, + configurable: true, + }); + } catch {} + const index = Object.create(ContentIndexCtor.prototype || Object.prototype); + if (typeof index.add !== 'function') { + index.add = async () => undefined; + } + if (typeof index.delete !== 'function') { + index.delete = async () => undefined; + } + if (typeof index.getAll !== 'function') { + index.getAll = async () => []; + } + if (typeof ServiceWorkerRegistration === 'undefined') return; + const defineIndex = (key) => { + try { + Object.defineProperty(ServiceWorkerRegistration.prototype, key, { + get: () => index, + configurable: true, + }); + return true; + } catch { + return false; + } + }; + if (!('contentIndex' in ServiceWorkerRegistration.prototype)) { + defineIndex('contentIndex'); + } + if (!('index' in ServiceWorkerRegistration.prototype)) { + defineIndex('index'); + } })();`; }