diff --git a/scripts/check-creepjs-headless.js b/scripts/check-creepjs-headless.js new file mode 100644 index 0000000..c8a7313 --- /dev/null +++ b/scripts/check-creepjs-headless.js @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * End-to-end check for CreepJS headless/stealth indicators. + * + * Usage: + * node scripts/check-creepjs-headless.js + * node scripts/check-creepjs-headless.js --compare-stealth + * node scripts/check-creepjs-headless.js --binary ./cli/target/release/agent-browser + */ + +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); + +const args = process.argv.slice(2); +const getArgValue = (name, fallback) => { + const index = args.indexOf(name); + if (index === -1 || index + 1 >= args.length) return fallback; + return args[index + 1]; +}; + +const binary = getArgValue('--binary', join(rootDir, 'cli', 'target', 'release', 'agent-browser')); +const sessionPrefix = getArgValue('--session-prefix', 'creepjs-e2e'); +const compareStealth = args.includes('--compare-stealth'); +const targetUrl = getArgValue('--url', 'https://abrahamjuliot.github.io/creepjs/'); + +const extractionScript = `(() => { + const headless = globalThis.Fingerprint?.headless ?? null; + const toNumber = (value) => (typeof value === 'number' ? value : null); + return { + found: !!headless, + metrics: headless ? { + chromium: !!headless.chromium, + likeHeadless: toNumber(headless.likeHeadlessRating), + headless: toNumber(headless.headlessRating), + stealth: toNumber(headless.stealthRating), + raw: headless, + } : null, + navigator: { + userAgent: navigator.userAgent, + userAgentData: navigator.userAgentData ? navigator.userAgentData.toJSON?.() ?? null : null, + language: navigator.language, + languages: navigator.languages, + platform: navigator.platform, + webdriver: navigator.webdriver, + webdriverInNavigator: ('webdriver' in navigator), + }, + window: { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + screenX: window.screenX, + screenY: window.screenY, + }, + intl: { + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + }; +})()`; + +function runCommand(commandArgs, options = {}) { + const result = spawnSync(binary, commandArgs, { encoding: 'utf8' }); + if (result.status !== 0 && !options.allowFailure) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + throw new Error( + `Command failed: ${binary} ${commandArgs.join(' ')}\n` + + `${stderr || stdout || `exit code ${result.status}`}` + ); + } + return result; +} + +function withSessionArgs(session, stealth) { + const base = ['--session', session]; + if (stealth === false) { + base.push('--stealth', 'false'); + } + return base; +} + +function runSingleCheck({ stealth, runId }) { + const session = `${sessionPrefix}-${runId}-${stealth ? 'stealth-on' : 'stealth-off'}`; + + runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true }); + + try { + runCommand([...withSessionArgs(session, stealth), 'open', targetUrl]); + runCommand([ + ...withSessionArgs(session, stealth), + 'wait', + '--fn', + '!!(window.Fingerprint && window.Fingerprint.headless)', + ]); + runCommand([...withSessionArgs(session, stealth), 'wait', '2000']); + + const evalResult = runCommand([ + ...withSessionArgs(session, stealth), + 'eval', + '--json', + extractionScript, + ]); + + const payload = JSON.parse(evalResult.stdout); + return { + session, + stealth, + url: targetUrl, + extracted: payload?.data?.result ?? null, + }; + } finally { + runCommand([...withSessionArgs(session, stealth), 'close'], { allowFailure: true }); + } +} + +function main() { + const runId = Date.now(); + const checks = compareStealth ? [true, false] : [true]; + const results = checks.map((stealth) => runSingleCheck({ stealth, runId })); + + const output = { + binary, + compareStealth, + timestamp: new Date().toISOString(), + results, + }; + + console.log(JSON.stringify(output, null, 2)); +} + +main(); diff --git a/src/browser.ts b/src/browser.ts index debaab7..e7ff4e3 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -27,7 +27,11 @@ import { decryptData, ENCRYPTION_KEY_ENV, } from './state-utils.js'; -import { STEALTH_CHROMIUM_ARGS, applyStealthScripts } from './stealth.js'; +import { + STEALTH_CHROMIUM_ARGS, + applyStealthScripts, + type StealthScriptOptions, +} from './stealth.js'; /** * Returns the default Playwright timeout in milliseconds for standard operations. @@ -114,6 +118,12 @@ export interface StealthStatus { providerManaged: boolean; } +interface StealthContextDefaults { + locale?: string; + timezoneId?: string; + extraHTTPHeaders?: Record; +} + /** * Manages the Playwright browser lifecycle with multiple tabs/windows */ @@ -143,6 +153,10 @@ export class BrowserManager { private colorScheme: 'light' | 'dark' | 'no-preference' | null = null; private stealthEnabled: boolean = false; private stealthConnectionKind: StealthConnectionKind = 'local'; + private contextLocale: string | undefined = undefined; + private contextTimezoneId: string | undefined = undefined; + private contextHeaders: Record | undefined = undefined; + private contextUserAgent: string | undefined = undefined; /** * Set the persistent color scheme preference. @@ -212,13 +226,130 @@ export class BrowserManager { }; } + private normalizeLocaleTag(locale?: string): string | undefined { + if (!locale) return undefined; + const cleaned = locale.trim().split(',')[0]?.split(';')[0]?.replace(/_/g, '-'); + if (!cleaned) return undefined; + try { + return new Intl.Locale(cleaned).toString(); + } catch { + return undefined; + } + } + + private buildAcceptLanguageHeader(locale: string): string { + const baseLanguage = locale.split('-')[0]; + if (!baseLanguage || baseLanguage === locale) { + return `${locale};q=0.9`; + } + return `${locale},${baseLanguage};q=0.9`; + } + + private getHeaderValue( + headers: Record | undefined, + name: string + ): string | undefined { + if (!headers) return undefined; + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) return value; + } + return undefined; + } + + private resolveStealthLocale(headers?: Record): string { + const headerLocale = this.getHeaderValue(headers, 'accept-language'); + const normalizedHeaderLocale = this.normalizeLocaleTag(headerLocale); + if (normalizedHeaderLocale) return normalizedHeaderLocale; + + const candidates = [ + process.env.AGENT_BROWSER_LOCALE, + process.env.LC_ALL, + process.env.LC_MESSAGES, + process.env.LANG, + Intl.DateTimeFormat().resolvedOptions().locale, + ]; + for (const candidate of candidates) { + const normalized = this.normalizeLocaleTag(candidate); + if (normalized) return normalized; + } + return 'en-US'; + } + + private resolveStealthTimezoneId(): string | undefined { + const candidates = [ + process.env.AGENT_BROWSER_TIMEZONE, + process.env.TZ, + Intl.DateTimeFormat().resolvedOptions().timeZone, + ]; + for (const value of candidates) { + const timezone = value?.trim(); + if (!timezone) continue; + if (timezone === 'UTC' || timezone.includes('/')) return timezone; + } + return undefined; + } + + private buildStealthContextDefaults( + policy: StealthPolicy, + headers?: Record + ): StealthContextDefaults { + if (!policy.enabled) { + return { extraHTTPHeaders: headers }; + } + + const locale = this.resolveStealthLocale(headers); + const timezoneId = this.resolveStealthTimezoneId(); + const hasAcceptLanguage = this.getHeaderValue(headers, 'accept-language') !== undefined; + const extraHTTPHeaders = hasAcceptLanguage + ? headers + : { + ...(headers ?? {}), + 'Accept-Language': this.buildAcceptLanguageHeader(locale), + }; + + return { + locale, + timezoneId, + extraHTTPHeaders, + }; + } + + private extractChromiumVersion(versionText: string): string | undefined { + const match = versionText.match(/(\d+\.\d+\.\d+\.\d+)/); + return match?.[1]; + } + + private buildStealthChromiumUserAgent(chromeVersion: string): string { + const platform = os.platform(); + let osToken = 'X11; Linux x86_64'; + if (platform === 'darwin') { + osToken = 'Macintosh; Intel Mac OS X 10_15_7'; + } else if (platform === 'win32') { + osToken = 'Windows NT 10.0; Win64; x64'; + } + return ( + `Mozilla/5.0 (${osToken}) AppleWebKit/537.36 ` + + `(KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36` + ); + } + + private getStealthUserAgentVersionHint(): string | undefined { + const deviceUA = devices['Desktop Chrome']?.userAgent; + if (!deviceUA) return undefined; + return this.extractChromiumVersion(deviceUA); + } + /** * Apply context init-script stealth patches when policy allows. */ - private async applyStealthIfEnabled(context: BrowserContext): Promise { + private async applyStealthIfEnabled( + context: BrowserContext, + options: StealthScriptOptions = {} + ): Promise { const policy = this.getStealthPolicy(); if (!policy.applyInitScripts) return; - await applyStealthScripts(context); + await applyStealthScripts(context, options); this.logStealthPolicy('init-script applied'); } @@ -377,9 +508,13 @@ export class BrowserManager { context = this.contexts[this.contexts.length - 1]; } else if (this.browser) { context = await this.browser.newContext({ + ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }), + ...(this.contextUserAgent && { userAgent: this.contextUserAgent }), + ...(this.contextLocale && { locale: this.contextLocale }), + ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(this.colorScheme && { colorScheme: this.colorScheme }), }); - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); @@ -988,7 +1123,7 @@ export class BrowserManager { } const context = contexts[0]; - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); const page = pages[0] ?? (await context.newPage()); @@ -1127,11 +1262,11 @@ export class BrowserManager { // Kernel browsers launch with a default context and page if (contexts.length === 0) { context = await browser.newContext(); - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); page = await context.newPage(); } else { context = contexts[0]; - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } @@ -1203,11 +1338,11 @@ export class BrowserManager { if (contexts.length === 0) { context = await browser.newContext(); - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); page = await context.newPage(); } else { context = contexts[0]; - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); const pages = context.pages(); page = pages[0] ?? (await context.newPage()); } @@ -1279,6 +1414,12 @@ export class BrowserManager { this.colorScheme = options.colorScheme; } this.stealthEnabled = options.stealth ?? false; + this.contextLocale = this.stealthEnabled + ? this.resolveStealthLocale(options.headers) + : undefined; + this.contextTimezoneId = this.stealthEnabled ? this.resolveStealthTimezoneId() : undefined; + this.contextHeaders = undefined; + this.contextUserAgent = options.userAgent; // -p flag takes precedence over AGENT_BROWSER_PROVIDER. const provider = options.provider ?? process.env.AGENT_BROWSER_PROVIDER; @@ -1335,13 +1476,36 @@ export class BrowserManager { browserType === 'firefox' ? firefox : browserType === 'webkit' ? webkit : chromium; const stealthPolicy = this.getStealthPolicy(browserType); + const contextDefaults = this.buildStealthContextDefaults(stealthPolicy, options.headers); + const extraHTTPHeaders = contextDefaults.extraHTTPHeaders; + this.contextLocale = contextDefaults.locale; + this.contextTimezoneId = contextDefaults.timezoneId; + this.contextHeaders = contextDefaults.extraHTTPHeaders; + + let contextUserAgent = options.userAgent; + if (!contextUserAgent && stealthPolicy.enabled && browserType === 'chromium') { + const versionHint = this.getStealthUserAgentVersionHint(); + if (versionHint) { + contextUserAgent = this.buildStealthChromiumUserAgent(versionHint); + } + } + this.contextUserAgent = contextUserAgent; // Build base args array with file access flags and stealth args when policy allows. const fileAccessArgs = options.allowFileAccess ? ['--allow-file-access-from-files', '--allow-file-access'] : []; const stealthArgs = stealthPolicy.applyChromiumArgs ? STEALTH_CHROMIUM_ARGS : []; - const implicitArgs = [...fileAccessArgs, ...stealthArgs]; + const hasUserAgentArg = options.args?.some((arg) => arg.startsWith('--user-agent=')); + const launchUserAgentArgs = + !hasUserAgentArg && + !options.userAgent && + stealthPolicy.enabled && + browserType === 'chromium' && + contextUserAgent + ? [`--user-agent=${contextUserAgent}`] + : []; + const implicitArgs = [...fileAccessArgs, ...stealthArgs, ...launchUserAgentArgs]; const baseArgs = options.args ? [...implicitArgs, ...options.args] : implicitArgs.length > 0 @@ -1375,8 +1539,10 @@ export class BrowserManager { executablePath: options.executablePath, args: allArgs, viewport, - extraHTTPHeaders: options.headers, - userAgent: options.userAgent, + extraHTTPHeaders, + userAgent: contextUserAgent, + ...(this.contextLocale && { locale: this.contextLocale }), + ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), @@ -1392,8 +1558,10 @@ export class BrowserManager { executablePath: options.executablePath, args: baseArgs, viewport, - extraHTTPHeaders: options.headers, - userAgent: options.userAgent, + extraHTTPHeaders, + userAgent: contextUserAgent, + ...(this.contextLocale && { locale: this.contextLocale }), + ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), @@ -1408,6 +1576,14 @@ export class BrowserManager { }); this.cdpEndpoint = null; + if (!options.userAgent && stealthPolicy.enabled && browserType === 'chromium') { + const runtimeVersion = this.extractChromiumVersion(this.browser.version()); + if (runtimeVersion) { + contextUserAgent = this.buildStealthChromiumUserAgent(runtimeVersion); + this.contextUserAgent = contextUserAgent; + } + } + // Check for auto-load state file (supports encrypted files) let storageState: | string @@ -1477,16 +1653,18 @@ export class BrowserManager { context = await this.browser.newContext({ viewport, - extraHTTPHeaders: options.headers, - userAgent: options.userAgent, + extraHTTPHeaders, + userAgent: contextUserAgent, storageState, + ...(this.contextLocale && { locale: this.contextLocale }), + ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(options.proxy && { proxy: options.proxy }), ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false, ...(this.colorScheme && { colorScheme: this.colorScheme }), }); } - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); @@ -1564,7 +1742,7 @@ export class BrowserManager { this.cdpEndpoint = cdpEndpoint; for (const context of contexts) { - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(10000); this.contexts.push(context); this.setupContextTracking(context); @@ -1820,9 +1998,13 @@ export class BrowserManager { const context = await this.browser.newContext({ viewport: viewport === undefined ? { width: 1280, height: 720 } : viewport, + ...(this.contextHeaders && { extraHTTPHeaders: this.contextHeaders }), + ...(this.contextUserAgent && { userAgent: this.contextUserAgent }), + ...(this.contextLocale && { locale: this.contextLocale }), + ...(this.contextTimezoneId && { timezoneId: this.contextTimezoneId }), ...(this.colorScheme && { colorScheme: this.colorScheme }), }); - await this.applyStealthIfEnabled(context); + await this.applyStealthIfEnabled(context, { locale: this.contextLocale }); context.setDefaultTimeout(getDefaultTimeout()); this.contexts.push(context); this.setupContextTracking(context); @@ -2564,6 +2746,10 @@ export class BrowserManager { this.colorScheme = null; this.stealthEnabled = false; this.stealthConnectionKind = 'local'; + this.contextLocale = undefined; + this.contextTimezoneId = undefined; + this.contextHeaders = undefined; + this.contextUserAgent = undefined; this.refMap = {}; this.lastSnapshot = ''; this.frameCallback = null; diff --git a/src/stealth.test.ts b/src/stealth.test.ts index caaf22b..5b5ad23 100644 --- a/src/stealth.test.ts +++ b/src/stealth.test.ts @@ -51,4 +51,47 @@ describe('Stealth mode', () => { expect(signals.ownNavigator).toBe(false); expect(signals.ownPrototype).toBe(false); }); + + it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => { + const previousLocale = process.env.AGENT_BROWSER_LOCALE; + process.env.AGENT_BROWSER_LOCALE = 'fr-FR'; + + try { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const languageSignals = await browser.getPage().evaluate(() => ({ + language: navigator.language, + languages: navigator.languages, + })); + + expect(languageSignals.language).toBe('fr-FR'); + expect(languageSignals.languages).toEqual(['fr-FR', 'fr']); + } finally { + if (previousLocale === undefined) { + delete process.env.AGENT_BROWSER_LOCALE; + } else { + process.env.AGENT_BROWSER_LOCALE = previousLocale; + } + } + }); + + it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => { + browser = new BrowserManager(); + await browser.launch({ headless: true, stealth: true }); + + const userAgentSignals = await browser.getPage().evaluate(async () => { + const pageUA = navigator.userAgent; + const workerUA = await new Promise((resolve) => { + const source = 'postMessage(navigator.userAgent);'; + const blob = new Blob([source], { type: 'application/javascript' }); + const worker = new Worker(URL.createObjectURL(blob)); + worker.onmessage = (event) => resolve(String(event.data)); + }); + return { pageUA, workerUA }; + }); + + expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome'); + expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome'); + }); }); diff --git a/src/stealth.ts b/src/stealth.ts index f975a82..cc3b58f 100644 --- a/src/stealth.ts +++ b/src/stealth.ts @@ -8,6 +8,10 @@ import type { BrowserContext } from 'playwright-core'; +export interface StealthScriptOptions { + locale?: string; +} + /** * Chromium args that reduce automation fingerprint. * Intended to be merged into the user-supplied args array at launch time. @@ -18,22 +22,52 @@ export const STEALTH_CHROMIUM_ARGS: string[] = ['--disable-blink-features=Automa * Apply all stealth patches to a BrowserContext. * Must be called BEFORE any page is created / navigated. */ -export async function applyStealthScripts(context: BrowserContext): Promise { - await context.addInitScript({ content: buildStealthScript() }); +export async function applyStealthScripts( + context: BrowserContext, + options: StealthScriptOptions = {} +): Promise { + await context.addInitScript({ content: buildStealthScript(options) }); } -function buildStealthScript(): string { +function normalizeLocale(locale?: string): string | undefined { + if (!locale) return undefined; + const trimmed = locale.trim(); + if (!trimmed) return undefined; + const cleaned = trimmed.split(',')[0]?.split(';')[0]?.replace(/_/g, '-'); + if (!cleaned) return undefined; + try { + return new Intl.Locale(cleaned).toString(); + } catch { + return undefined; + } +} + +function deriveLanguages(locale?: string): string[] { + const normalized = normalizeLocale(locale) ?? 'en-US'; + const base = normalized.split('-')[0]; + if (!base || base === normalized) return [normalized]; + return [normalized, base]; +} + +function buildStealthScript(options: StealthScriptOptions): string { + const locale = normalizeLocale(options.locale) ?? 'en-US'; + const languages = deriveLanguages(locale); + const configScript = `const __abStealth = ${JSON.stringify({ locale, languages })};`; + // Each patch is an IIFE so variable scoping is clean return [ + configScript, patchNavigatorWebdriver(), patchChromeRuntime(), + patchNavigatorLanguages(), patchNavigatorPlugins(), patchNavigatorPermissions(), patchWebGLVendor(), patchCdcProperties(), - patchIframeContentWindow(), + patchWindowDimensions(), patchNavigatorHardwareConcurrency(), patchMediaDevices(), + patchUserAgentData(), patchUserAgent(), patchPerformanceMemory(), ].join('\n'); @@ -56,6 +90,9 @@ function patchNavigatorWebdriver(): string { removeWebdriver(navigator); removeWebdriver(Object.getPrototypeOf(navigator)); removeWebdriver(Navigator.prototype); + if (typeof WorkerNavigator !== 'undefined') { + removeWebdriver(WorkerNavigator.prototype); + } })();`; } @@ -67,14 +104,59 @@ function patchChromeRuntime(): string { return `(function(){ if (!window.chrome) { window.chrome = {}; } if (!window.chrome.runtime) { - window.chrome.runtime = { - connect: function(){}, - sendMessage: function(){}, + const makeEvent = () => ({ + addListener: () => {}, + removeListener: () => {}, + hasListener: () => false, + hasListeners: () => false, + dispatch: () => {}, + }); + const makePort = () => ({ + name: '', + sender: undefined, + disconnect: () => {}, + onDisconnect: makeEvent(), + onMessage: makeEvent(), + postMessage: () => {}, + }); + const runtime = { + id: undefined, + connect: () => makePort(), + sendMessage: () => undefined, + onConnect: makeEvent(), + onMessage: makeEvent(), }; + Object.defineProperty(window.chrome, 'runtime', { + value: runtime, + configurable: true, + }); } })();`; } +/** + * Keep navigator.language + navigator.languages aligned with launch locale. + */ +function patchNavigatorLanguages(): string { + return `(function(){ + const config = (typeof __abStealth === 'object' && __abStealth) ? __abStealth : null; + if (!config || !Array.isArray(config.languages) || config.languages.length === 0) return; + const locale = typeof config.locale === 'string' ? config.locale : config.languages[0]; + try { + Object.defineProperty(navigator, 'language', { + get: () => locale, + configurable: true, + }); + } catch {} + try { + Object.defineProperty(navigator, 'languages', { + get: () => config.languages.slice(), + configurable: true, + }); + } catch {} +})();`; +} + /** * Inject a realistic navigator.plugins array. * Headless Chrome reports an empty PluginArray; real Chrome always has a few. @@ -115,14 +197,42 @@ function patchNavigatorPlugins(): string { */ function patchNavigatorPermissions(): string { return `(function(){ - if (!navigator.permissions) return; + if (!navigator.permissions || !navigator.permissions.query) return; const origQuery = navigator.permissions.query.bind(navigator.permissions); - navigator.permissions.query = (params) => { - if (params.name === 'notifications') { - return Promise.resolve({ state: Notification.permission, onchange: null }); + const makePermissionStatus = (state) => { + if (typeof PermissionStatus !== 'undefined') { + const status = Object.create(PermissionStatus.prototype); + Object.defineProperty(status, 'state', { + value: state, + writable: false, + enumerable: true, + }); + Object.defineProperty(status, 'onchange', { + value: null, + writable: true, + enumerable: true, + }); + return status; } - return origQuery(params); + return { state, onchange: null }; }; + const patchedQuery = new Proxy(origQuery, { + apply(target, thisArg, argList) { + const params = argList && argList[0]; + if (params && params.name === 'notifications') { + const state = (typeof Notification !== 'undefined' && Notification.permission) || 'default'; + return Promise.resolve(makePermissionStatus(state)); + } + return Reflect.apply(target, navigator.permissions, argList); + } + }); + try { + Object.defineProperty(navigator.permissions, 'query', { + value: patchedQuery, + configurable: true, + writable: true, + }); + } catch {} })();`; } @@ -179,20 +289,56 @@ function patchCdcProperties(): string { * contentWindow on cross-origin iframes: Playwright sometimes returns null * where real browsers return a (restricted) Window object. */ -function patchIframeContentWindow(): string { +function patchWindowDimensions(): string { return `(function(){ - const orig = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow'); - if (orig && orig.get) { - Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { - get: function() { - const w = orig.get.call(this); - if (w === null) { - return window; - } - return w; - }, - configurable: true, - }); + const widthDelta = 12; + const heightDelta = 74; + const patchWidth = + !Number.isFinite(window.outerWidth) || + window.outerWidth === 0 || + Math.abs(window.outerWidth - window.innerWidth) <= 1; + const patchHeight = + !Number.isFinite(window.outerHeight) || + window.outerHeight === 0 || + Math.abs(window.outerHeight - window.innerHeight) <= 1; + if (patchWidth) { + try { + Object.defineProperty(window, 'outerWidth', { + get: () => Math.max(window.innerWidth + widthDelta, window.innerWidth), + configurable: true, + }); + } catch {} + } + if (patchHeight) { + try { + Object.defineProperty(window, 'outerHeight', { + get: () => Math.max(window.innerHeight + heightDelta, window.innerHeight), + configurable: true, + }); + } catch {} + } + const patchScreenPosition = + (!Number.isFinite(window.screenX) || !Number.isFinite(window.screenY)) || + (window.screenX === 0 && window.screenY === 0 && (patchWidth || patchHeight)); + if (patchScreenPosition) { + try { + Object.defineProperty(window, 'screenX', { + get: () => 16, + configurable: true, + }); + Object.defineProperty(window, 'screenY', { + get: () => 72, + configurable: true, + }); + Object.defineProperty(window, 'screenLeft', { + get: () => 16, + configurable: true, + }); + Object.defineProperty(window, 'screenTop', { + get: () => 72, + configurable: true, + }); + } catch {} } })();`; } @@ -256,6 +402,63 @@ function patchUserAgent(): string { })();`; } +/** + * Ensure userAgentData does not expose "HeadlessChrome" brand tokens. + */ +function patchUserAgentData(): string { + return `(function(){ + const uaData = navigator.userAgentData; + if (!uaData) return; + const sanitizeBrand = (brand) => { + if (typeof brand !== 'string') return brand; + return brand.replace(/HeadlessChrome/gi, 'Google Chrome'); + }; + const patchBrandList = (value) => { + if (!Array.isArray(value)) return value; + return value.map((entry) => ({ + ...entry, + brand: sanitizeBrand(entry.brand), + })); + }; + const patched = Object.create(Object.getPrototypeOf(uaData)); + Object.defineProperties(patched, { + brands: { + get: () => patchBrandList(uaData.brands), + enumerable: true, + }, + mobile: { + get: () => uaData.mobile, + enumerable: true, + }, + platform: { + get: () => uaData.platform, + enumerable: true, + }, + }); + patched.toJSON = () => ({ + brands: patchBrandList(uaData.brands), + mobile: uaData.mobile, + platform: uaData.platform, + }); + patched.getHighEntropyValues = async (hints) => { + const values = await uaData.getHighEntropyValues(hints); + if (values && typeof values === 'object') { + if ('brands' in values) values.brands = patchBrandList(values.brands); + if ('fullVersionList' in values) { + values.fullVersionList = patchBrandList(values.fullVersionList); + } + } + return values; + }; + try { + Object.defineProperty(navigator, 'userAgentData', { + get: () => patched, + configurable: true, + }); + } catch {} +})();`; +} + /** * Provide a fake performance.memory (Chrome-only, non-standard). * Headless Chrome omits this; some detectors check for its presence.