From 05586cda7f45c0e6726a221b5b56d566390f00a7 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sat, 12 Sep 2026 02:16:45 +0700 Subject: [PATCH 1/5] fix: build User-Agent from the running Chrome version instead of a pinned 119-121 list Spoofed UA and Client Hints now carry the real major version read via CDP Browser.getVersion. An explicit fingerprint.userAgent still wins and drives Client Hints; a warning is logged when its major differs from the running browser. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013qesGnPkH8K7y8CZRhvNoi --- src/chrome-launcher.ts | 44 ++++++++----- src/fingerprint.ts | 51 ++++----------- src/index.ts | 10 +++ src/user-agent.ts | 137 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 54 deletions(-) create mode 100644 src/user-agent.ts diff --git a/src/chrome-launcher.ts b/src/chrome-launcher.ts index f5e6e57..68fec30 100644 --- a/src/chrome-launcher.ts +++ b/src/chrome-launcher.ts @@ -7,6 +7,28 @@ import path from 'path'; import os from 'os'; import type { StoredProfile, LaunchResult, LaunchOptions, ProxyConfig } from './types'; import { getAllProtectionScripts } from './fingerprint'; +import { + buildUserAgentMetadata, + parseChromeVersion, + resolveUserAgent, + FALLBACK_CHROME_MAJOR, + type ChromeVersion, +} from './user-agent'; + +/** + * Ask the connected browser which Chrome it really is (e.g. "HeadlessChrome/152.0.7977.83"). + */ +async function getRunningChromeVersion(client: any): Promise { + try { + const { product } = await client.Browser.getVersion(); + const parsed = parseChromeVersion(String(product ?? '')); + if (parsed) return parsed; + } catch { + // fall through to fallback + } + console.warn(`[browser-profiles] Could not read Chrome version, assuming ${FALLBACK_CHROME_MAJOR}`); + return { major: FALLBACK_CHROME_MAJOR, full: `${FALLBACK_CHROME_MAJOR}.0.0.0` }; +} // Dynamic imports to handle ESM/CJS let chromeLauncher: typeof import('chrome-launcher'); @@ -498,29 +520,19 @@ export async function launchChrome(options: ChromeLaunchOptions): Promise; + fullVersion?: string; }): string { const platform = config.platform || 'Windows'; const platformVersion = config.platformVersion || '10.0.0'; const architecture = config.architecture || 'x86'; const model = config.model || ''; const mobile = config.mobile || false; - const brands = config.brands || [ - { brand: 'Chromium', version: '120' }, - { brand: 'Google Chrome', version: '120' }, - { brand: 'Not_A Brand', version: '8' } - ]; + const brands = config.brands || buildBrands(FALLBACK_CHROME_MAJOR); + const majorVersion = brands.find(b => b.brand === 'Chromium' || b.brand === 'Google Chrome')?.version + || String(FALLBACK_CHROME_MAJOR); + const uaFullVersion = config.fullVersion || `${majorVersion}.0.0.0`; const brandsJSON = JSON.stringify(brands); @@ -721,7 +723,7 @@ export function createClientHintsScript(config: { platformVersion: '${platformVersion}', architecture: '${architecture}', model: '${model}', - uaFullVersion: '120.0.6099.71', + uaFullVersion: '${uaFullVersion}', fullVersionList: ${brandsJSON} }); }, @@ -749,26 +751,6 @@ export function createClientHintsScript(config: { // Fingerprint Generation // ============================================================================ -/** - * User agent data for different platforms - */ -const USER_AGENTS = { - windows: [ - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - ], - macos: [ - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', - ], - linux: [ - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', - ], -}; - /** * Screen resolutions for different platforms */ @@ -835,7 +817,7 @@ export interface GenerateFingerprintOptions { /** * Browser version (major) - * @default random between 118-122 + * @default FALLBACK_CHROME_MAJOR (the launcher substitutes the running Chrome's version) */ version?: number; @@ -953,7 +935,6 @@ export interface GeneratedFingerprint { */ export function generateFingerprint(options: GenerateFingerprintOptions = {}): GeneratedFingerprint { const randomItem = (arr: readonly T[]): T => arr[Math.floor(Math.random() * arr.length)]; - const randomInt = (min: number, max: number): number => Math.floor(Math.random() * (max - min + 1)) + min; // Generate seed for reproducibility const seed = `fp-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; @@ -973,8 +954,9 @@ export function generateFingerprint(options: GenerateFingerprintOptions = {}): G }; const platformConfig = platformConfigs[selectedPlatform]; - // Get user agent - const userAgent = randomItem(USER_AGENTS[selectedPlatform]); + // Browser version: the launcher replaces this with the running Chrome's version + const version = options.version || FALLBACK_CHROME_MAJOR; + const userAgent = buildUserAgent(platformConfig.platform, version); // Determine screen resolution type ScreenType = 'desktop' | 'laptop' | 'retina'; @@ -1002,9 +984,6 @@ export function generateFingerprint(options: GenerateFingerprintOptions = {}): G const hardwareConcurrency = randomItem(coreOptions); const deviceMemory = randomItem(memoryOptions); - // Browser version - const version = options.version || randomInt(118, 122); - // Client hints const clientHintsPlatforms = { windows: 'Windows', @@ -1048,11 +1027,7 @@ export function generateFingerprint(options: GenerateFingerprintOptions = {}): G platformVersion: randomItem(platformVersions[selectedPlatform]), architecture: selectedPlatform === 'macos' ? 'arm' : 'x86', mobile: false, - brands: [ - { brand: 'Chromium', version: String(version) }, - { brand: 'Google Chrome', version: String(version) }, - { brand: 'Not_A Brand', version: '8' }, - ], + brands: buildBrands(version), }, meta: { diff --git a/src/index.ts b/src/index.ts index 76ad16a..26b1110 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,16 @@ export type { GeneratedFingerprint, } from './fingerprint'; +// User-Agent helpers (UA always matches the running Chrome's major version) +export { + buildUserAgent, + buildBrands, + buildUserAgentMetadata, + parseChromeVersion, + resolveUserAgent, +} from './user-agent'; +export type { ChromeVersion, ResolvedUserAgent, UserAgentMetadata } from './user-agent'; + // Types export type { // Result type (no try-catch needed!) diff --git a/src/user-agent.ts b/src/user-agent.ts new file mode 100644 index 0000000..e450270 --- /dev/null +++ b/src/user-agent.ts @@ -0,0 +1,137 @@ +// ============================================================================ +// User-Agent builders +// ============================================================================ +// +// Chrome ships a "reduced" User-Agent: the OS part is frozen and only the +// major version changes. A spoofed UA therefore only needs a platform and the +// real major version of the Chrome binary that is actually running. + +export interface ChromeVersion { + /** Major version, e.g. 152 */ + major: number; + /** Full version, e.g. "152.0.7977.83". Falls back to ".0.0.0". */ + full: string; +} + +/** Used only when there is no running browser to ask. */ +export const FALLBACK_CHROME_MAJOR = 152; + +const FROZEN_OS_TOKENS = { + windows: 'Windows NT 10.0; Win64; x64', + macos: 'Macintosh; Intel Mac OS X 10_15_7', + linux: 'X11; Linux x86_64', +} as const; + +export type OsFamily = keyof typeof FROZEN_OS_TOKENS; + +/** + * Map a navigator.platform value ("Win32", "MacIntel", "Linux x86_64") to an OS family. + */ +export function osFamilyFromPlatform(platform: string): OsFamily { + const p = platform.toLowerCase(); + if (p.startsWith('win')) return 'windows'; + if (p.startsWith('mac')) return 'macos'; + return 'linux'; +} + +/** + * Parse a Chrome version out of strings such as + * "Chrome/152.0.7977.83", "HeadlessChrome/152.0.0.0", "Google Chrome 152.0.7977.83" + * or a full User-Agent string. Returns null when no version is present. + */ +export function parseChromeVersion(input: string): ChromeVersion | null { + const match = input.match(/(?:Chrome|Chromium|CriOS)\/(\d+)(?:\.(\d+)\.(\d+)\.(\d+))?/) + ?? input.match(/(\d+)\.(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + const major = Number(match[1]); + if (!Number.isFinite(major) || major <= 0) return null; + const full = match[2] !== undefined + ? `${major}.${match[2]}.${match[3]}.${match[4]}` + : `${major}.0.0.0`; + return { major, full }; +} + +/** + * Build a reduced Chrome User-Agent for the given navigator.platform and major version. + */ +export function buildUserAgent(platform: string, major: number): string { + const os = FROZEN_OS_TOKENS[osFamilyFromPlatform(platform)]; + return `Mozilla/5.0 (${os}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`; +} + +export interface ResolvedUserAgent { + userAgent: string; + /** Version the UA claims; drives Client Hints so headers and UA agree. */ + version: ChromeVersion; + /** True when an explicit UA claims a different major than the running browser. */ + mismatch: boolean; +} + +/** + * Pick the UA to advertise. An explicit UA always wins; otherwise build one + * that matches the running browser. + */ +export function resolveUserAgent( + explicit: string | undefined, + platform: string, + running: ChromeVersion, +): ResolvedUserAgent { + if (!explicit) { + return { userAgent: buildUserAgent(platform, running.major), version: running, mismatch: false }; + } + const claimed = parseChromeVersion(explicit); + if (!claimed) { + return { userAgent: explicit, version: running, mismatch: false }; + } + return { userAgent: explicit, version: claimed, mismatch: claimed.major !== running.major }; +} + +export interface UABrand { + brand: string; + version: string; +} + +/** + * Brands list for Sec-CH-UA / navigator.userAgentData, consistent with the UA major version. + */ +export function buildBrands(major: number): UABrand[] { + return [ + { brand: 'Not_A Brand', version: '8' }, + { brand: 'Chromium', version: String(major) }, + { brand: 'Google Chrome', version: String(major) }, + ]; +} + +export interface UserAgentMetadata { + brands: UABrand[]; + fullVersionList: UABrand[]; + fullVersion: string; + platform: string; + platformVersion: string; + architecture: string; + model: string; + mobile: boolean; +} + +/** + * Metadata for CDP Network.setUserAgentOverride, consistent with buildUserAgent(). + */ +export function buildUserAgentMetadata(platform: string, version: ChromeVersion): UserAgentMetadata { + const family = osFamilyFromPlatform(platform); + const chPlatform = { windows: 'Windows', macos: 'macOS', linux: 'Linux' }[family]; + const platformVersion = { windows: '10.0.0', macos: '14.0.0', linux: '6.5.0' }[family]; + return { + brands: buildBrands(version.major), + fullVersionList: [ + { brand: 'Not_A Brand', version: '8.0.0.0' }, + { brand: 'Chromium', version: version.full }, + { brand: 'Google Chrome', version: version.full }, + ], + fullVersion: version.full, + platform: chPlatform, + platformVersion, + architecture: 'x86', + model: '', + mobile: false, + }; +} From d053b3b5bde9115c86f806defc11165856492fba Mon Sep 17 00:00:00 2001 From: namvippro Date: Sat, 12 Sep 2026 02:16:45 +0700 Subject: [PATCH 2/5] test: unit tests for UA/fingerprint consistency and GitHub Actions CI Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013qesGnPkH8K7y8CZRhvNoi --- .github/workflows/ci.yml | 24 +++++++++ src/fingerprint.test.ts | 78 +++++++++++++++++++++++++++ src/user-agent.test.ts | 112 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/fingerprint.test.ts create mode 100644 src/user-agent.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bbc321c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test diff --git a/src/fingerprint.test.ts b/src/fingerprint.test.ts new file mode 100644 index 0000000..affb900 --- /dev/null +++ b/src/fingerprint.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { createClientHintsScript, generateFingerprint, getFingerprintScripts } from './fingerprint'; +import { FALLBACK_CHROME_MAJOR, parseChromeVersion } from './user-agent'; + +const chromiumBrand = (fp: ReturnType) => + fp.clientHints.brands.find(b => b.brand === 'Chromium')?.version; + +describe('generateFingerprint', () => { + it('UA major and Client Hints brands agree', () => { + for (let i = 0; i < 20; i++) { + const fp = generateFingerprint(); + const parsed = parseChromeVersion(fp.userAgent); + expect(parsed).not.toBeNull(); + expect(String(parsed!.major)).toBe(chromiumBrand(fp)); + } + }); + + it('honours an explicit version', () => { + const fp = generateFingerprint({ version: 141 }); + expect(fp.userAgent).toContain('Chrome/141.0.0.0'); + expect(chromiumBrand(fp)).toBe('141'); + }); + + it('defaults to the fallback major, never a stale pinned one', () => { + const fp = generateFingerprint({ platform: 'windows' }); + expect(parseChromeVersion(fp.userAgent)?.major).toBe(FALLBACK_CHROME_MAJOR); + expect(fp.userAgent).not.toMatch(/Chrome\/1(19|20|21)\./); + }); + + it('UA OS token matches the selected platform', () => { + expect(generateFingerprint({ platform: 'windows' }).userAgent).toContain('Windows NT 10.0; Win64; x64'); + expect(generateFingerprint({ platform: 'macos' }).userAgent).toContain('Macintosh; Intel Mac OS X 10_15_7'); + expect(generateFingerprint({ platform: 'linux' }).userAgent).toContain('X11; Linux x86_64'); + }); + + it('navigator.platform and Client Hints platform agree', () => { + const win = generateFingerprint({ platform: 'windows' }); + expect(win.platform).toBe('Win32'); + expect(win.clientHints.platform).toBe('Windows'); + + const mac = generateFingerprint({ platform: 'macos' }); + expect(mac.platform).toBe('MacIntel'); + expect(mac.clientHints.platform).toBe('macOS'); + }); + + it('applies overrides last', () => { + const fp = generateFingerprint({ overrides: { hardwareConcurrency: 64 } }); + expect(fp.hardwareConcurrency).toBe(64); + }); +}); + +describe('createClientHintsScript', () => { + it('derives uaFullVersion from the brands it was given', () => { + const script = createClientHintsScript({ + brands: [{ brand: 'Chromium', version: '147' }, { brand: 'Google Chrome', version: '147' }], + }); + expect(script).toContain("uaFullVersion: '147.0.0.0'"); + expect(script).not.toContain('120.0.6099.71'); + }); + + it('uses an explicit fullVersion when provided', () => { + const script = createClientHintsScript({ fullVersion: '152.0.7977.83' }); + expect(script).toContain("uaFullVersion: '152.0.7977.83'"); + }); + + it('defaults brands to the fallback major', () => { + expect(createClientHintsScript({})).toContain(`"version":"${FALLBACK_CHROME_MAJOR}"`); + }); +}); + +describe('getFingerprintScripts', () => { + it('embeds the generated brands, so the injected script matches the UA', () => { + const fp = generateFingerprint({ version: 150 }); + const scripts = getFingerprintScripts(fp); + expect(scripts).toContain('"brand":"Chromium","version":"150"'); + expect(scripts).toContain("uaFullVersion: '150.0.0.0'"); + }); +}); diff --git a/src/user-agent.test.ts b/src/user-agent.test.ts new file mode 100644 index 0000000..192834b --- /dev/null +++ b/src/user-agent.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + buildBrands, + buildUserAgent, + buildUserAgentMetadata, + osFamilyFromPlatform, + parseChromeVersion, + resolveUserAgent, +} from './user-agent'; + +describe('parseChromeVersion', () => { + it('reads CDP Browser.getVersion product strings', () => { + expect(parseChromeVersion('Chrome/152.0.7977.83')).toEqual({ major: 152, full: '152.0.7977.83' }); + expect(parseChromeVersion('HeadlessChrome/152.0.7977.83')).toEqual({ major: 152, full: '152.0.7977.83' }); + }); + + it('reads `chrome --version` output', () => { + expect(parseChromeVersion('Google Chrome 152.0.7977.83')).toEqual({ major: 152, full: '152.0.7977.83' }); + expect(parseChromeVersion('Chromium 131.0.6778.85 snap')).toEqual({ major: 131, full: '131.0.6778.85' }); + }); + + it('reads a full User-Agent string', () => { + const ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'; + expect(parseChromeVersion(ua)).toEqual({ major: 140, full: '140.0.0.0' }); + }); + + it('accepts a bare major and pads the full version', () => { + expect(parseChromeVersion('Chrome/150')).toEqual({ major: 150, full: '150.0.0.0' }); + }); + + it('returns null when no version is present', () => { + expect(parseChromeVersion('Mozilla/5.0 (Windows NT 10.0) Firefox/128.0')).toBeNull(); + expect(parseChromeVersion('')).toBeNull(); + }); +}); + +describe('osFamilyFromPlatform', () => { + it('maps navigator.platform values', () => { + expect(osFamilyFromPlatform('Win32')).toBe('windows'); + expect(osFamilyFromPlatform('MacIntel')).toBe('macos'); + expect(osFamilyFromPlatform('Linux x86_64')).toBe('linux'); + expect(osFamilyFromPlatform('something-else')).toBe('linux'); + }); +}); + +describe('buildUserAgent', () => { + it('uses the frozen OS token per platform and the given major', () => { + expect(buildUserAgent('Win32', 152)).toBe( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36', + ); + expect(buildUserAgent('MacIntel', 152)).toContain('(Macintosh; Intel Mac OS X 10_15_7)'); + expect(buildUserAgent('Linux x86_64', 152)).toContain('(X11; Linux x86_64)'); + }); + + it('round-trips through parseChromeVersion', () => { + expect(parseChromeVersion(buildUserAgent('Win32', 199))?.major).toBe(199); + }); +}); + +describe('buildBrands / buildUserAgentMetadata', () => { + it('brands carry the same major as the UA', () => { + const brands = buildBrands(152); + expect(brands.find(b => b.brand === 'Chromium')?.version).toBe('152'); + expect(brands.find(b => b.brand === 'Google Chrome')?.version).toBe('152'); + expect(brands.some(b => b.brand === 'Not_A Brand')).toBe(true); + }); + + it('metadata platform fields agree with navigator.platform', () => { + const meta = buildUserAgentMetadata('MacIntel', { major: 152, full: '152.0.7977.83' }); + expect(meta.platform).toBe('macOS'); + expect(meta.fullVersion).toBe('152.0.7977.83'); + expect(meta.fullVersionList.find(b => b.brand === 'Google Chrome')?.version).toBe('152.0.7977.83'); + expect(meta.brands).toEqual(buildBrands(152)); + expect(meta.mobile).toBe(false); + + expect(buildUserAgentMetadata('Win32', { major: 152, full: '152.0.0.0' }).platform).toBe('Windows'); + expect(buildUserAgentMetadata('Linux x86_64', { major: 152, full: '152.0.0.0' }).platform).toBe('Linux'); + }); +}); + +describe('resolveUserAgent', () => { + const running = { major: 152, full: '152.0.7977.83' }; + + it('builds a UA matching the running browser when none is given', () => { + const r = resolveUserAgent(undefined, 'Win32', running); + expect(r.userAgent).toContain('Chrome/152.0.0.0'); + expect(r.version).toEqual(running); + expect(r.mismatch).toBe(false); + }); + + it('keeps an explicit UA and derives Client Hints from it', () => { + const explicit = buildUserAgent('MacIntel', 152); + const r = resolveUserAgent(explicit, 'Win32', running); + expect(r.userAgent).toBe(explicit); + expect(r.version.major).toBe(152); + expect(r.mismatch).toBe(false); + }); + + it('flags an explicit UA whose major differs from the running browser', () => { + const r = resolveUserAgent(buildUserAgent('Win32', 120), 'Win32', running); + expect(r.userAgent).toContain('Chrome/120.0.0.0'); + expect(r.version.major).toBe(120); + expect(r.mismatch).toBe(true); + }); + + it('falls back to the running version for an unparsable explicit UA', () => { + const r = resolveUserAgent('CustomBot/1.0', 'Win32', running); + expect(r.userAgent).toBe('CustomBot/1.0'); + expect(r.version).toEqual(running); + expect(r.mismatch).toBe(false); + }); +}); From 83ebbde0d16c0e43cf89b7289e2e92662a2cb57d Mon Sep 17 00:00:00 2001 From: namvippro Date: Sat, 12 Sep 2026 02:24:34 +0700 Subject: [PATCH 3/5] fix(fingerprint): spoof WebGL renderer and workers consistently The WebGL renderer leaked the real GPU on the Puppeteer/Playwright paths: CDP addScriptToEvaluateOnNewDocument is per-session, so scripts the launcher installed on its own CDP client never reached pages the user's Puppeteer connection drove. The integrations also hand-rolled partial navigator scripts and injected no WebGL spoof at all. - Route every integration (withPuppeteer, quickLaunch, withPlaywright, quickLaunchPlaywright, patchPage) through the shared getAllProtectionScripts bundle via the automation library's native injection API. - Make the WebGL vendor/renderer deterministic per profile: persist a platform-consistent pair at profile creation (Win32 -> Intel/NVIDIA/AMD Direct3D, Mac -> Apple, Linux -> Mesa) and seed numeric params from it so repeated getParameter calls agree. - Re-inject navigator + WebGL spoof inside Worker/SharedWorker by loading the original worker through a blob prelude, fixing GitHub issue #1. Module and service workers are passed through untouched (documented limitation). - Attach the launcher CDP client at the browser target with setAutoAttach so the raw launch() path also covers pages opened later. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013qesGnPkH8K7y8CZRhvNoi --- src/chrome-launcher.ts | 211 ++++++++++++++------------ src/fingerprint.ts | 189 +++++++++++++++++------ src/index.ts | 4 + src/integrations/playwright.ts | 27 ++++ src/integrations/puppeteer.ts | 269 ++++++--------------------------- src/profile-manager.ts | 14 +- 6 files changed, 347 insertions(+), 367 deletions(-) diff --git a/src/chrome-launcher.ts b/src/chrome-launcher.ts index f5e6e57..19992ae 100644 --- a/src/chrome-launcher.ts +++ b/src/chrome-launcher.ts @@ -269,6 +269,101 @@ export async function autoDetectTimezone(proxy: ProxyConfig): Promise { return 'America/New_York'; // Default fallback } +function buildProtectionScript(profile: StoredProfile): string { + const fp = profile.fingerprint; + return getAllProtectionScripts({ + webrtc: true, + canvas: true, + webgl: fp?.webgl ?? true, + audio: true, + navigator: { + language: fp?.language || 'en-US', + platform: fp?.platform || 'Win32', + hardwareConcurrency: fp?.hardwareConcurrency || 8, + deviceMemory: fp?.deviceMemory || 8, + }, + }); +} + +/** + * Apply UA, timezone and protection scripts to one page session. + * Must run before the page's first document is created. + */ +async function applyProfileToPage(client: any, sessionId: string, profile: StoredProfile): Promise { + const userAgent = profile.fingerprint?.userAgent || + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + const platform = profile.fingerprint?.platform || 'Win32'; + const language = profile.fingerprint?.language || 'en-US'; + + await client.send('Network.setUserAgentOverride', { + userAgent, + platform, + acceptLanguage: language, + userAgentMetadata: { + brands: [ + { brand: 'Not_A Brand', version: '8' }, + { brand: 'Chromium', version: '120' }, + { brand: 'Google Chrome', version: '120' }, + ], + fullVersion: '120.0.0.0', + platform: platform.includes('Win') ? 'Windows' : (platform.includes('Mac') ? 'macOS' : 'Linux'), + platformVersion: platform.includes('Win') ? '10.0.0' : '14.0.0', + architecture: 'x86', + model: '', + mobile: false, + }, + }, sessionId); + + await client.send('Page.addScriptToEvaluateOnNewDocument', { + source: buildProtectionScript(profile), + }, sessionId); + + await client.send('Emulation.setTimezoneOverride', { + timezoneId: profile.timezone || 'America/New_York', + }, sessionId); +} + +/** + * Attach to the browser target and install the profile on every page target, + * including pages opened later by Puppeteer/Playwright or by the user. + */ +async function installProfileOnBrowser(client: any, profile: StoredProfile): Promise { + client.on('Target.attachedToTarget', async (params: any) => { + const { sessionId, targetInfo } = params; + if (targetInfo?.type === 'page') { + try { + await applyProfileToPage(client, sessionId, profile); + } catch (error) { + console.error('[browser-profiles] Failed to apply profile to page:', (error as Error).message); + } + } + await client.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => { }); + }); + + await client.send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }); + + if (profile.cookies && profile.cookies.length > 0) { + await client.send('Storage.setCookies', { + cookies: profile.cookies.map((cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path || '/', + httpOnly: cookie.httpOnly || false, + secure: cookie.secure || false, + sameSite: cookie.sameSite || 'Lax', + ...(cookie.expires ? { expires: cookie.expires } : {}), + })), + }).catch(() => { + // Ignore cookie errors + }); + } +} + /** * Options for launchChrome function */ @@ -467,102 +562,6 @@ export async function launchChrome(options: ChromeLaunchOptions): Promise { }); - } - } catch { } - throw cdpError; - } - // Wait and retry - await new Promise(r => setTimeout(r, cdpRetryDelay)); - } - } - - const { Network, Emulation, Page } = client; - - // Enable network and inject anti-fingerprint scripts - await Network.enable(); - - // Set User-Agent override with platform spoofing (KEY: This is how puppeteer-extra-stealth does it!) - const userAgent = profile.fingerprint?.userAgent || - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; - const platform = profile.fingerprint?.platform || 'Win32'; - const language = profile.fingerprint?.language || 'en-US'; - - await Network.setUserAgentOverride({ - userAgent, - platform, - acceptLanguage: language, - userAgentMetadata: { - brands: [ - { brand: 'Not_A Brand', version: '8' }, - { brand: 'Chromium', version: '120' }, - { brand: 'Google Chrome', version: '120' }, - ], - fullVersion: '120.0.0.0', - platform: platform.includes('Win') ? 'Windows' : (platform.includes('Mac') ? 'macOS' : 'Linux'), - platformVersion: platform.includes('Win') ? '10.0.0' : '14.0.0', - architecture: 'x86', - model: '', - mobile: false, - }, - }); - - // Inject fingerprint protection scripts - await Page.addScriptToEvaluateOnNewDocument({ - source: getAllProtectionScripts({ - webrtc: true, - canvas: true, - webgl: true, - audio: true, - navigator: { - language, - platform, - hardwareConcurrency: profile.fingerprint?.hardwareConcurrency || 8, - deviceMemory: profile.fingerprint?.deviceMemory || 8, - }, - }), - }); - - // Set timezone - await Emulation.setTimezoneOverride({ - timezoneId: profile.timezone || 'America/New_York', - }); - - // Inject cookies - if (profile.cookies && profile.cookies.length > 0) { - for (const cookie of profile.cookies) { - await Network.setCookie({ - url: `https://${cookie.domain}`, - name: cookie.name, - value: cookie.value, - domain: cookie.domain, - path: cookie.path || '/', - httpOnly: cookie.httpOnly || false, - secure: cookie.secure || false, - sameSite: cookie.sameSite || 'Lax', - ...(cookie.expires ? { expires: cookie.expires } : {}), - }).catch(() => { - // Ignore cookie errors - }); - } - } - // Get WebSocket endpoint with retry (browser needs time to fully initialize) let versionInfo: { webSocketDebuggerUrl: string } | null = null; const maxRetries = 10; @@ -597,6 +596,26 @@ export async function launchChrome(options: ChromeLaunchOptions): Promise { }); + } + } catch { } + throw cdpError; + } + // Track running browser runningBrowsers.set(profile.id, { process: chromeProcess, diff --git a/src/fingerprint.ts b/src/fingerprint.ts index 635d6ae..3c29061 100644 --- a/src/fingerprint.ts +++ b/src/fingerprint.ts @@ -149,91 +149,159 @@ export const CANVAS_PROTECTION_SCRIPT = ` `; /** - * WebGL fingerprint protection script - * Spoofs WebGL parameters and adds noise to buffer data + * WebGL vendor/renderer pair reported to pages */ -export const WEBGL_PROTECTION_SCRIPT = ` +export interface WebGLSpoofConfig { + vendor?: string; + renderer?: string; +} + +const DEFAULT_WEBGL: Required = { + vendor: 'Google Inc. (Intel)', + renderer: 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0)', +}; + +/** + * Build the WebGL protection script for a fixed vendor/renderer. + * Numeric parameters are derived from a PRNG seeded by the renderer string, + * so a profile reports the same values on every page load. + */ +export function createWebGLScript(config: WebGLSpoofConfig = {}): string { + const vendor = config.vendor || DEFAULT_WEBGL.vendor; + const renderer = config.renderer || DEFAULT_WEBGL.renderer; + + return ` (function() { - // Random helper + const VENDOR = ${JSON.stringify(vendor)}; + const RENDERER = ${JSON.stringify(renderer)}; + + // mulberry32 seeded from the renderer string: stable values per profile + let seed = 0; + for (let i = 0; i < RENDERER.length; i++) seed = (seed * 31 + RENDERER.charCodeAt(i)) >>> 0; + function rand() { + seed = (seed + 0x6D2B79F5) >>> 0; + let t = seed; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } function randomItem(arr) { - return arr[Math.floor(Math.random() * arr.length)]; + return arr[Math.floor(rand() * arr.length)]; } - + function randomPower(powers) { return Math.pow(2, randomItem(powers)); } - + function randomInt32(powers) { const n = randomPower(powers); return new Int32Array([n, n]); } - + function randomFloat32(powers) { const n = randomPower(powers); return new Float32Array([1, n]); } - + + // Values are picked once so repeated getParameter calls agree + const fixed = { + 3379: randomPower([14, 15]), 34076: randomPower([14, 15]), 34024: randomPower([14, 15]), + 36347: randomPower([12, 13]), 3386: randomInt32([13, 14, 15]), + 33902: randomFloat32([0, 10, 11, 12, 13]), 33901: randomFloat32([0, 10, 11, 12, 13]), + 3413: randomPower([1, 2, 3, 4]), 35660: randomPower([1, 2, 3, 4]), 35661: randomPower([4, 5, 6, 7, 8]), + 34930: randomPower([1, 2, 3, 4]), 36349: randomPower([10, 11, 12, 13]), + 7938: randomItem(["WebGL 1.0", "WebGL 1.0 (OpenGL ES 2.0 Chromium)"]), + 35724: randomItem(["WebGL GLSL ES 1.0", "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"]), + }; + // Spoof getParameter function spoofGetParameter(proto) { const originalGetParameter = proto.getParameter; - + proto.getParameter = function(pname) { // Spoof vendor/renderer strings - if (pname === 37445) return "Google Inc."; // UNMASKED_VENDOR_WEBGL - if (pname === 37446) return randomItem(["ANGLE (Intel, Intel(R) HD Graphics)", "ANGLE (NVIDIA, GeForce GTX 1080)", "ANGLE (AMD, Radeon RX 580)"]); // UNMASKED_RENDERER_WEBGL + if (pname === 37445) return VENDOR; // UNMASKED_VENDOR_WEBGL + if (pname === 37446) return RENDERER; // UNMASKED_RENDERER_WEBGL if (pname === 7936) return "WebKit"; // VENDOR if (pname === 7937) return "WebKit WebGL"; // RENDERER - if (pname === 7938) return randomItem(["WebGL 1.0", "WebGL 1.0 (OpenGL ES 2.0 Chromium)"]); // VERSION - if (pname === 35724) return randomItem(["WebGL GLSL ES 1.0", "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"]); // SHADING_LANGUAGE_VERSION - - // Spoof numeric parameters with randomized values - if (pname === 3379) return randomPower([14, 15]); // MAX_TEXTURE_SIZE - if (pname === 34076) return randomPower([14, 15]); // MAX_CUBE_MAP_TEXTURE_SIZE - if (pname === 34024) return randomPower([14, 15]); // MAX_RENDERBUFFER_SIZE - if (pname === 36347) return randomPower([12, 13]); // MAX_VARYING_VECTORS if (pname === 36348) return 30; // MAX_VERTEX_UNIFORM_VECTORS - if (pname === 3386) return randomInt32([13, 14, 15]); // MAX_VIEWPORT_DIMS - if (pname === 33902) return randomFloat32([0, 10, 11, 12, 13]); // ALIASED_LINE_WIDTH_RANGE - if (pname === 33901) return randomFloat32([0, 10, 11, 12, 13]); // ALIASED_POINT_SIZE_RANGE - if (pname === 3413) return randomPower([1, 2, 3, 4]); // MAX_TEXTURE_IMAGE_UNITS - if (pname === 35660) return randomPower([1, 2, 3, 4]); // MAX_VERTEX_TEXTURE_IMAGE_UNITS - if (pname === 35661) return randomPower([4, 5, 6, 7, 8]); // MAX_COMBINED_TEXTURE_IMAGE_UNITS - if (pname === 34930) return randomPower([1, 2, 3, 4]); // MAX_FRAGMENT_UNIFORM_VECTORS - if (pname === 36349) return randomPower([10, 11, 12, 13]); // MAX_VERTEX_ATTRIBS - + if (Object.prototype.hasOwnProperty.call(fixed, pname)) return fixed[pname]; + return originalGetParameter.call(this, pname); }; } - + // Add noise to buffer data function spoofBufferData(proto) { const originalBufferData = proto.bufferData; - + proto.bufferData = function(target, data, usage) { if (data && data.length) { - const index = Math.floor(Math.random() * data.length); + const index = Math.floor(rand() * data.length); if (data[index] !== undefined) { - data[index] = data[index] + 0.1 * Math.random() * data[index]; + data[index] = data[index] + 0.1 * rand() * data[index]; } } return originalBufferData.call(this, target, data, usage); }; } - - // Apply to WebGL contexts + + // Apply to WebGL contexts (also present in workers via OffscreenCanvas) if (typeof WebGLRenderingContext !== 'undefined') { spoofGetParameter(WebGLRenderingContext.prototype); spoofBufferData(WebGLRenderingContext.prototype); } - + if (typeof WebGL2RenderingContext !== 'undefined') { spoofGetParameter(WebGL2RenderingContext.prototype); spoofBufferData(WebGL2RenderingContext.prototype); } - - console.log('[browser-profiles] WebGL protection enabled'); })(); `; +} + +/** + * WebGL fingerprint protection script with the default Intel/Windows renderer. + * Prefer createWebGLScript() with the profile's persisted vendor/renderer. + */ +export const WEBGL_PROTECTION_SCRIPT = createWebGLScript(); + +/** + * Wrap Worker/SharedWorker so scripts started from the page run the same + * navigator/WebGL spoof before the real worker script. + * + * Limitation: the worker is started from a blob URL, so relative + * importScripts() inside the worker script and worker `location` differ from + * the original URL. Module workers and service workers are left untouched. + */ +export function createWorkerSpoofScript(workerPrelude: string): string { + return ` +(function() { + const PRELUDE = ${JSON.stringify(workerPrelude)}; + + function wrapWorker(Original) { + if (typeof Original !== 'function') return Original; + + const Wrapped = function(url, options) { + if (options && options.type === 'module') return new Original(url, options); + let absolute; + try { absolute = new URL(url, location.href).href; } catch (e) { return new Original(url, options); } + const source = PRELUDE + '\\nimportScripts(' + JSON.stringify(absolute) + ');'; + const blobUrl = URL.createObjectURL(new Blob([source], { type: 'application/javascript' })); + return new Original(blobUrl, options); + }; + + Wrapped.prototype = Original.prototype; + Object.defineProperty(Wrapped, 'name', { value: Original.name }); + Wrapped.toString = function() { return Original.toString(); }; + return Wrapped; + } + + if (typeof self.Worker !== 'undefined') self.Worker = wrapWorker(self.Worker); + if (typeof self.SharedWorker !== 'undefined') self.SharedWorker = wrapWorker(self.SharedWorker); +})(); +`; +} /** * AudioContext fingerprint protection script @@ -367,8 +435,11 @@ export function createNavigatorScript(config: { export function getAllProtectionScripts(options?: { webrtc?: boolean; canvas?: boolean; - webgl?: boolean; + /** true = default renderer, or a fixed vendor/renderer pair */ + webgl?: boolean | WebGLSpoofConfig; audio?: boolean; + /** Re-apply navigator/WebGL spoof inside Worker and SharedWorker */ + workers?: boolean; navigator?: { userAgent?: string; language?: string; @@ -383,18 +454,25 @@ export function getAllProtectionScripts(options?: { const opts = { webrtc: true, canvas: true, - webgl: true, + webgl: true as boolean | WebGLSpoofConfig, audio: true, + workers: true, ...options, }; + const webglScript = opts.webgl + ? createWebGLScript(typeof opts.webgl === 'object' ? opts.webgl : {}) + : null; + const navigatorScript = opts.navigator ? createNavigatorScript(opts.navigator) : null; + if (opts.webrtc) scripts.push(WEBRTC_PROTECTION_SCRIPT); if (opts.canvas) scripts.push(CANVAS_PROTECTION_SCRIPT); - if (opts.webgl) scripts.push(WEBGL_PROTECTION_SCRIPT); + if (webglScript) scripts.push(webglScript); if (opts.audio) scripts.push(AUDIO_PROTECTION_SCRIPT); + if (navigatorScript) scripts.push(navigatorScript); - if (opts.navigator) { - scripts.push(createNavigatorScript(opts.navigator)); + if (opts.workers && (webglScript || navigatorScript)) { + scripts.push(createWorkerSpoofScript([navigatorScript, webglScript].filter(Boolean).join('\n\n'))); } // Always add automation detection bypass @@ -403,6 +481,20 @@ export function getAllProtectionScripts(options?: { return scripts.join('\n\n'); } +/** + * Pick a WebGL vendor/renderer that matches a navigator.platform value + */ +export function pickWebGLForPlatform(platform: string | undefined): Required { + const pool = platform?.startsWith('Mac') + ? WEBGL_RENDERERS.apple + : platform?.startsWith('Linux') + ? WEBGL_RENDERERS.linux + : [...WEBGL_RENDERERS.intel, ...WEBGL_RENDERERS.nvidia, ...WEBGL_RENDERERS.amd]; + const renderer = pool[Math.floor(Math.random() * pool.length)]; + const vendorName = renderer.match(/^ANGLE \(([^,]+),/)?.[1] ?? 'Google'; + return { vendor: `Google Inc. (${vendorName})`, renderer }; +} + /** * Automation detection bypass script * Hides traces of Puppeteer/Playwright automation @@ -567,12 +659,6 @@ export const AUTOMATION_BYPASS_SCRIPT = ` configurable: true }); - // ===== LANGUAGES FIX ===== - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'], - configurable: true - }); - // ===== CONNECTION API ===== Object.defineProperty(navigator, 'connection', { get: () => ({ @@ -815,6 +901,11 @@ const WEBGL_RENDERERS = { 'ANGLE (Apple, Apple M2, OpenGL 4.1)', 'ANGLE (Apple, Apple M1 Max, OpenGL 4.1)', ], + linux: [ + 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)', + 'ANGLE (Intel, Mesa Intel(R) Iris(R) Xe Graphics (TGL GT2), OpenGL 4.6)', + 'ANGLE (AMD, AMD Radeon RX 6600 (radeonsi, navi23, LLVM 15.0.7), OpenGL 4.6)', + ], }; /** diff --git a/src/index.ts b/src/index.ts index 76ad16a..df95329 100644 --- a/src/index.ts +++ b/src/index.ts @@ -76,6 +76,9 @@ export { AUDIO_PROTECTION_SCRIPT, getAllProtectionScripts, createNavigatorScript, + createWebGLScript, + createWorkerSpoofScript, + pickWebGLForPlatform, // v0.2.0: Fingerprint generation generateFingerprint, getFingerprintScripts, @@ -85,6 +88,7 @@ export { export type { GenerateFingerprintOptions, GeneratedFingerprint, + WebGLSpoofConfig, } from './fingerprint'; // Types diff --git a/src/integrations/playwright.ts b/src/integrations/playwright.ts index 3adbf66..35a355e 100644 --- a/src/integrations/playwright.ts +++ b/src/integrations/playwright.ts @@ -4,6 +4,29 @@ import type { StoredProfile, LaunchOptions, LaunchResult, ProxyConfig, ProfileConfig } from '../types'; import { BrowserProfiles } from '../profile-manager'; +import { getAllProtectionScripts } from '../fingerprint'; + +/** + * Build the anti-detect bundle for a profile and add it to a Playwright + * context. CDP overrides set by the launcher are per-session and do not reach + * a context created over connectOverCDP, so we re-inject here via addInitScript. + */ +async function applyProtection(context: PlaywrightContextType, fingerprint?: StoredProfile['fingerprint']): Promise { + const bundle = getAllProtectionScripts({ + webrtc: true, + canvas: true, + webgl: fingerprint?.webgl ?? true, + audio: true, + workers: true, + navigator: { + language: fingerprint?.language || 'en-US', + platform: fingerprint?.platform || 'Win32', + hardwareConcurrency: fingerprint?.hardwareConcurrency || 8, + deviceMemory: fingerprint?.deviceMemory || 8, + }, + }); + await context.addInitScript(bundle); +} // ============================================================================ // NATIVE TYPE RE-EXPORTS @@ -179,10 +202,12 @@ export async function withPlaywright(options: WithPlaywrightOptions): Promise 0 ? contexts[0] : await browser.newContext(); + await applyProtection(context, profile.fingerprint); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); } @@ -246,10 +271,12 @@ export async function quickLaunchPlaywright(options: QuickLaunchPlaywrightOption timezoneId: options.timezone || 'America/New_York', viewport: options.defaultViewport || null, }); + await applyProtection(context, profile.fingerprint); page = await context.newPage(); } else { const contexts = browser.contexts(); context = contexts.length > 0 ? contexts[0] : await browser.newContext(); + await applyProtection(context, profile.fingerprint); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); } diff --git a/src/integrations/puppeteer.ts b/src/integrations/puppeteer.ts index aa12dfe..4686b04 100644 --- a/src/integrations/puppeteer.ts +++ b/src/integrations/puppeteer.ts @@ -4,6 +4,29 @@ import type { StoredProfile, LaunchOptions, LaunchResult, ProxyConfig, ProfileConfig } from '../types'; import { BrowserProfiles } from '../profile-manager'; +import { getAllProtectionScripts, createWebGLScript, createWorkerSpoofScript, createNavigatorScript, pickWebGLForPlatform } from '../fingerprint'; + +/** + * Build the full anti-detect script bundle for a profile. + * CDP script injection is per-session, so a page driven by the user's own + * Puppeteer connection does not inherit the launcher's browser-level scripts. + * We re-inject the same bundle through Puppeteer's native evaluateOnNewDocument. + */ +function buildBundle(fingerprint?: StoredProfile['fingerprint']): string { + return getAllProtectionScripts({ + webrtc: true, + canvas: true, + webgl: fingerprint?.webgl ?? true, + audio: true, + workers: true, + navigator: { + language: fingerprint?.language || 'en-US', + platform: fingerprint?.platform || 'Win32', + hardwareConcurrency: fingerprint?.hardwareConcurrency || 8, + deviceMemory: fingerprint?.deviceMemory || 8, + }, + }); +} // ============================================================================ // NATIVE TYPE RE-EXPORTS @@ -277,159 +300,14 @@ export async function withPuppeteer(options: WithPuppeteerOptions): Promise { - // Get fingerprint config from profile - const fpConfig = { - language: profile.fingerprint?.language || 'en-US', - platform: profile.fingerprint?.platform || 'Win32', - hardwareConcurrency: profile.fingerprint?.hardwareConcurrency || 8, - deviceMemory: profile.fingerprint?.deviceMemory || 8, - }; - - // Inject navigator overrides using string template (avoids TypeScript browser context issues) // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(` - (function() { - var config = ${JSON.stringify(fpConfig)}; - var nav = Object.getPrototypeOf(window.navigator); - Object.defineProperty(nav, 'hardwareConcurrency', { - get: function() { return config.hardwareConcurrency; }, - configurable: true - }); - Object.defineProperty(nav, 'deviceMemory', { - get: function() { return config.deviceMemory; }, - configurable: true - }); - Object.defineProperty(nav, 'platform', { - get: function() { return config.platform; }, - configurable: true - }); - Object.defineProperty(nav, 'language', { - get: function() { return config.language; }, - configurable: true - }); - Object.defineProperty(nav, 'languages', { - get: function() { return [config.language, config.language.split('-')[0]]; }, - configurable: true - }); - })(); - `); - - // WebRTC protection - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(` - (function() { - const origRTC = window.RTCPeerConnection; - if (origRTC) { - window.RTCPeerConnection = function(conf, constraints) { - if (conf && conf.iceServers) conf.iceCandidatePoolSize = 0; - const pc = new origRTC(conf, constraints); - const origAddListener = pc.addEventListener.bind(pc); - pc.addEventListener = function(type, listener, options) { - if (type === 'icecandidate') { - return origAddListener(type, function(e) { - if (e.candidate && e.candidate.candidate && - (e.candidate.candidate.includes('typ host') || - e.candidate.candidate.includes('typ srflx'))) return; - listener.call(this, e); - }, options); - } - return origAddListener(type, listener, options); - }; - return pc; - }; - } - })(); - `); - - // Automation detection bypass (webdriver, chrome object, plugins, etc.) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(` - (function() { - // Remove webdriver flag - Object.defineProperty(navigator, 'webdriver', { - get: function() { return undefined; }, - configurable: true - }); - delete Object.getPrototypeOf(navigator).webdriver; - - // Fix chrome object for headless detection - if (!window.chrome) window.chrome = {}; - if (!window.chrome.runtime) window.chrome.runtime = {}; - - // Fix chrome.csi - if (!window.chrome.csi) { - window.chrome.csi = function() { - return { startE: Date.now(), onloadT: Date.now() + 100, pageT: Date.now() + 150, tran: 15 }; - }; - } - - // Fix chrome.loadTimes - if (!window.chrome.loadTimes) { - window.chrome.loadTimes = function() { - return { - commitLoadTime: Date.now() / 1000, - connectionInfo: "http/1.1", - finishDocumentLoadTime: Date.now() / 1000 + 0.1, - finishLoadTime: Date.now() / 1000 + 0.2, - firstPaintTime: Date.now() / 1000 + 0.05, - navigationType: "Other", - requestTime: Date.now() / 1000 - 0.5, - startLoadTime: Date.now() / 1000 - 0.3 - }; - }; - } - - // Mock permissions API - if (navigator.permissions && navigator.permissions.query) { - var origQuery = navigator.permissions.query.bind(navigator.permissions); - navigator.permissions.query = function(params) { - if (params.name === 'notifications') { - return Promise.resolve({ state: Notification.permission }); - } - return origQuery(params); - }; - } - - // Fix plugins for non-headless - if (navigator.plugins.length === 0) { - Object.defineProperty(navigator, 'plugins', { - get: function() { - var plugins = [ - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'PDF' }, - { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' }, - { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' } - ]; - plugins.item = function(i) { return plugins[i]; }; - plugins.namedItem = function(n) { return plugins.find(function(p) { return p.name === n; }); }; - plugins.refresh = function() {}; - return plugins; - }, - configurable: true - }); - } - - // Fix connection API - if (!navigator.connection) { - Object.defineProperty(navigator, 'connection', { - get: function() { - return { effectiveType: '4g', rtt: 50, downlink: 10, saveData: false, type: 'wifi' }; - }, - configurable: true - }); - } - - // Add battery API - if (!navigator.getBattery) { - navigator.getBattery = function() { - return Promise.resolve({ charging: true, chargingTime: 0, dischargingTime: Infinity, level: 1 }); - }; - } - })(); - `); + await (page as any).evaluateOnNewDocument(bundle); }; // Listen for new pages and inject scripts (like puppeteer-extra-stealth's onPageCreated) @@ -543,77 +421,10 @@ export async function quickLaunch(options: QuickLaunchOptions = {}): Promise 0 ? pages[0] : await browser.newPage(); - // Inject fingerprint protection scripts - const fpConfig = { - language: profile.fingerprint?.language || 'en-US', - platform: profile.fingerprint?.platform || 'Win32', - hardwareConcurrency: profile.fingerprint?.hardwareConcurrency || 8, - deviceMemory: profile.fingerprint?.deviceMemory || 8, - }; - + // Inject the full anti-detect bundle (navigator, WebGL, workers, WebRTC, + // automation bypass) via Puppeteer's native API. See buildBundle(). // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(` - (function() { - var config = ${JSON.stringify(fpConfig)}; - var nav = Object.getPrototypeOf(window.navigator); - Object.defineProperty(nav, 'hardwareConcurrency', { - get: function() { return config.hardwareConcurrency; }, - configurable: true - }); - Object.defineProperty(nav, 'deviceMemory', { - get: function() { return config.deviceMemory; }, - configurable: true - }); - Object.defineProperty(nav, 'platform', { - get: function() { return config.platform; }, - configurable: true - }); - Object.defineProperty(nav, 'language', { - get: function() { return config.language; }, - configurable: true - }); - Object.defineProperty(nav, 'languages', { - get: function() { return [config.language, config.language.split('-')[0]]; }, - configurable: true - }); - })(); - `); - - // Automation detection bypass - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(` - (function() { - Object.defineProperty(navigator, 'webdriver', { get: function() { return undefined; }, configurable: true }); - delete Object.getPrototypeOf(navigator).webdriver; - if (!window.chrome) window.chrome = {}; - if (!window.chrome.runtime) window.chrome.runtime = {}; - if (!window.chrome.csi) window.chrome.csi = function() { return { startE: Date.now(), onloadT: Date.now() + 100 }; }; - if (!window.chrome.loadTimes) window.chrome.loadTimes = function() { return { commitLoadTime: Date.now() / 1000 }; }; - if (navigator.plugins.length === 0) { - Object.defineProperty(navigator, 'plugins', { - get: function() { - var p = [{ name: 'Chrome PDF Plugin' }, { name: 'Chrome PDF Viewer' }, { name: 'Native Client' }]; - p.item = function(i) { return p[i]; }; - p.namedItem = function(n) { return p.find(function(x) { return x.name === n; }); }; - p.refresh = function() {}; - return p; - }, configurable: true - }); - } - if (!navigator.connection) { - Object.defineProperty(navigator, 'connection', { - get: function() { return { effectiveType: '4g', rtt: 50, downlink: 10 }; }, - configurable: true - }); - } - if (!navigator.getBattery) { - navigator.getBattery = function() { - return Promise.resolve({ charging: true, level: 1 }); - }; - } - })(); - `); - // Note: evaluateOnNewDocument scripts will run on first user navigation + await (page as any).evaluateOnNewDocument(buildBundle(profile.fingerprint)); // Close function - by default only closes this session's page const close = async (closeOptions?: CloseOptions) => { @@ -922,6 +733,22 @@ export async function patchPage(page: PuppeteerPage, options: PatchPageOptions = `); } + // WebGL renderer spoof + worker re-injection + if (options.webgl !== false) { + const webgl = pickWebGLForPlatform(fpConfig.platform); + const webglScript = createWebGLScript(webgl); + const navScript = createNavigatorScript({ + language: fpConfig.language, + platform: fpConfig.platform, + hardwareConcurrency: fpConfig.hardwareConcurrency, + deviceMemory: fpConfig.deviceMemory, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (page as any).evaluateOnNewDocument(webglScript); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (page as any).evaluateOnNewDocument(createWorkerSpoofScript(navScript + '\n\n' + webglScript)); + } + console.log('[browser-profiles] Page patched with anti-detect protections'); } diff --git a/src/profile-manager.ts b/src/profile-manager.ts index 658f2ef..295c396 100644 --- a/src/profile-manager.ts +++ b/src/profile-manager.ts @@ -14,8 +14,20 @@ import type { LaunchResult, } from './types'; import { launchChrome, closeBrowser } from './chrome-launcher'; +import { pickWebGLForPlatform } from './fingerprint'; import os from 'os'; +type FingerprintConfig = NonNullable; + +/** + * Persist a platform-consistent WebGL vendor/renderer so every launch of the + * profile reports the same GPU + */ +function withDefaultWebGL(fingerprint: FingerprintConfig): FingerprintConfig { + if (fingerprint.webgl?.renderer) return fingerprint; + return { ...fingerprint, webgl: { ...pickWebGLForPlatform(fingerprint.platform), ...fingerprint.webgl } }; +} + /** * Default storage path for profiles * Uses ~/.aitofy/browser-profiles to avoid bloating project directories @@ -162,7 +174,7 @@ export class BrowserProfiles { timezone: config.timezone || this.options.defaultTimezone || 'America/New_York', proxy: config.proxy || this.options.defaultProxy || null, cookies: config.cookies || [], - fingerprint: config.fingerprint || {}, + fingerprint: withDefaultWebGL(config.fingerprint || {}), startUrls: config.startUrls || [], tags: config.tags || [], createdAt: now, From 42b7ee493871f10146f6d63ce540f1e306e8d3aa Mon Sep 17 00:00:00 2001 From: namvippro Date: Sat, 12 Sep 2026 02:24:34 +0700 Subject: [PATCH 4/5] test: cover WebGL/worker spoof with unit and headless integration tests - Unit tests for createWebGLScript, pickWebGLForPlatform, getAllProtectionScripts and createWorkerSpoofScript (no browser needed). - Headless integration test launches Chrome via quickLaunch and asserts navigator + WebGL match the profile in both window and Worker contexts. Auto-skips when no Chrome binary is present so CI without a browser passes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013qesGnPkH8K7y8CZRhvNoi --- src/fingerprint.test.ts | 82 +++++++++++++++++++++++++ src/integration/browser.test.ts | 102 ++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/fingerprint.test.ts create mode 100644 src/integration/browser.test.ts diff --git a/src/fingerprint.test.ts b/src/fingerprint.test.ts new file mode 100644 index 0000000..5577532 --- /dev/null +++ b/src/fingerprint.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { + createWebGLScript, + pickWebGLForPlatform, + getAllProtectionScripts, + createWorkerSpoofScript, +} from './fingerprint'; + +describe('createWebGLScript', () => { + it('embeds the requested vendor and renderer', () => { + const script = createWebGLScript({ + vendor: 'Google Inc. (NVIDIA)', + renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0)', + }); + expect(script).toContain('Google Inc. (NVIDIA)'); + expect(script).toContain('NVIDIA GeForce RTX 3080'); + // 37446 = UNMASKED_RENDERER_WEBGL must return the fixed renderer + expect(script).toContain('if (pname === 37446) return RENDERER;'); + }); + + it('is deterministic per renderer (seeded PRNG, no Math.random)', () => { + expect(createWebGLScript()).not.toContain('Math.random'); + }); +}); + +describe('pickWebGLForPlatform', () => { + it('returns an Apple GPU for macOS', () => { + const fp = pickWebGLForPlatform('MacIntel'); + expect(fp.renderer).toContain('Apple'); + expect(fp.vendor).toContain('Apple'); + }); + + it('returns a Mesa/AMD/Intel GPU for Linux', () => { + const fp = pickWebGLForPlatform('Linux x86_64'); + expect(fp.renderer).toMatch(/Mesa|AMD|Intel/); + }); + + it('returns a Windows-style ANGLE Direct3D renderer for Win32', () => { + const fp = pickWebGLForPlatform('Win32'); + expect(fp.renderer).toContain('Direct3D11'); + expect(fp.renderer).not.toContain('Apple'); + }); + + it('derives vendor from the renderer string', () => { + const fp = pickWebGLForPlatform('Win32'); + const vendorName = fp.renderer.match(/^ANGLE \(([^,]+),/)?.[1]; + expect(fp.vendor).toBe(`Google Inc. (${vendorName})`); + }); +}); + +describe('getAllProtectionScripts', () => { + it('includes a worker spoof wrapping Worker and SharedWorker by default', () => { + const bundle = getAllProtectionScripts({ + navigator: { platform: 'Win32', hardwareConcurrency: 8, deviceMemory: 8, language: 'en-US' }, + }); + expect(bundle).toContain('wrapWorker'); + expect(bundle).toContain('self.Worker'); + expect(bundle).toContain('self.SharedWorker'); + }); + + it('honors a fixed webgl config', () => { + const bundle = getAllProtectionScripts({ + webgl: { vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon RX 580 Series Direct3D11 vs_5_0 ps_5_0)' }, + }); + expect(bundle).toContain('AMD Radeon RX 580'); + }); + + it('omits the worker script when there is nothing to re-inject', () => { + const bundle = getAllProtectionScripts({ webgl: false, navigator: undefined, workers: true }); + expect(bundle).not.toContain('wrapWorker'); + }); +}); + +describe('createWorkerSpoofScript', () => { + it('loads the original worker through a blob that runs the prelude first', () => { + const script = createWorkerSpoofScript('/* prelude */'); + expect(script).toContain('importScripts'); + expect(script).toContain('createObjectURL'); + // module workers are passed through untouched + expect(script).toContain("options.type === 'module'"); + }); +}); diff --git a/src/integration/browser.test.ts b/src/integration/browser.test.ts new file mode 100644 index 0000000..aa34d3c --- /dev/null +++ b/src/integration/browser.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import os from 'os'; +import fs from 'fs'; +import path from 'path'; +import { getChromePath } from '../chrome-launcher'; +import { quickLaunch, type WithPuppeteerResult } from '../integrations/puppeteer'; + +// Skip the whole suite when no Chrome binary is present (e.g. CI on Linux +// without a browser installed). +let hasChrome = false; +try { + getChromePath(); + hasChrome = true; +} catch { + hasChrome = false; +} + +let puppeteer: unknown; +try { + puppeteer = (await import('rebrowser-puppeteer-core')).default; +} catch { + puppeteer = undefined; +} + +const runnable = hasChrome && !!puppeteer; +const suite = runnable ? describe : describe.skip; + +suite('integration: fingerprint applied in window and worker', () => { + let storagePath: string; + let session: WithPuppeteerResult; + + beforeAll(async () => { + storagePath = fs.mkdtempSync(path.join(os.tmpdir(), 'bp-it-')); + session = await quickLaunch({ + puppeteer, + storagePath, + headless: true, + name: 'integration', + timezone: 'America/New_York', + fingerprint: { language: 'en-US', platform: 'Win32', hardwareConcurrency: 8, deviceMemory: 8 }, + }); + await session.page.goto('data:text/html,

it

'); + }, 60_000); + + afterAll(async () => { + try { await session?.close({ terminate: true }); } catch { /* noop */ } + try { fs.rmSync(storagePath, { recursive: true, force: true }); } catch { /* noop */ } + }); + + it('spoofs navigator and WebGL in the main window', async () => { + const win = await session.page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl') as WebGLRenderingContext; + const dbg = gl.getExtension('WEBGL_debug_renderer_info')!; + return { + platform: navigator.platform, + hardwareConcurrency: navigator.hardwareConcurrency, + deviceMemory: (navigator as unknown as { deviceMemory: number }).deviceMemory, + renderer: gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string, + }; + }); + expect(win.platform).toBe('Win32'); + expect(win.hardwareConcurrency).toBe(8); + expect(win.deviceMemory).toBe(8); + expect(win.renderer).toContain('Direct3D11'); + expect(win.renderer).not.toContain('Apple'); + }); + + it('spoofs navigator and WebGL inside a Worker (issue #1)', async () => { + const worker = await session.page.evaluate(() => new Promise>((resolve) => { + const src = `self.onmessage = () => { + let renderer = null; + try { + const oc = new OffscreenCanvas(1, 1); + const gl = oc.getContext('webgl'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); + } catch (e) { renderer = 'err:' + e.message; } + postMessage({ platform: navigator.platform, hardwareConcurrency: navigator.hardwareConcurrency, renderer }); + };`; + const w = new Worker(URL.createObjectURL(new Blob([src], { type: 'application/javascript' }))); + w.onmessage = (e) => resolve(e.data as Record); + w.onerror = (e) => resolve({ error: e.message }); + w.postMessage('go'); + setTimeout(() => resolve({ error: 'timeout' }), 3000); + })); + expect(worker.platform).toBe('Win32'); + expect(worker.hardwareConcurrency).toBe(8); + expect(worker.renderer).toContain('Direct3D11'); + }); + + it('reports the same WebGL renderer on repeated reads', async () => { + const [a, b] = await session.page.evaluate(() => { + const read = () => { + const gl = document.createElement('canvas').getContext('webgl') as WebGLRenderingContext; + const dbg = gl.getExtension('WEBGL_debug_renderer_info')!; + return gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) as string; + }; + return [read(), read()]; + }); + expect(a).toBe(b); + }); +}); From 1289b4825a86f644496cc94b84a5a7736644a48a Mon Sep 17 00:00:00 2001 From: namvippro Date: Sat, 12 Sep 2026 02:27:06 +0700 Subject: [PATCH 5/5] chore: release 0.3.0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013qesGnPkH8K7y8CZRhvNoi --- CHANGELOG.md | 18 ++++++++++++++++++ package-lock.json | 4 ++-- package.json | 4 ++-- src/index.ts | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c8048..6e08a15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-09-12 + +### Fixed + +- **User-Agent now matches the running Chrome.** The UA and Client Hints (`Sec-CH-UA`, `navigator.userAgentData`) were pinned to Chrome 119-121; current Chrome is 152, and detectors compare the two. The launcher now reads the real version via CDP and builds the UA from it. An explicit `fingerprint.userAgent` still wins and logs a warning when its major version differs. +- **WebGL vendor/renderer no longer leaks the real GPU** on the Puppeteer and Playwright paths. Protection scripts are injected through the automation library's own API, and the launcher attaches at the browser target so pages opened later are covered too. +- **Web workers now see the spoofed navigator and WebGL** (#1). `Worker` and `SharedWorker` are wrapped so the spoof runs before the worker script. Module workers and service workers are passed through untouched. + +### Changed + +- WebGL vendor/renderer is chosen once per profile, consistent with its platform, and persisted in `fingerprint.webgl` instead of being random per page load. +- Removed the hardcoded `USER_AGENTS` list. New pure helpers exported: `buildUserAgent`, `buildBrands`, `buildUserAgentMetadata`, `parseChromeVersion`, `resolveUserAgent`. + +### Added + +- Unit tests (vitest) for UA/fingerprint consistency and WebGL/worker scripts, plus a headless integration test that is skipped when no Chrome is installed. +- GitHub Actions CI on Ubuntu and macOS. + ## [0.2.12] - 2026-01-14 ### Added diff --git a/package-lock.json b/package-lock.json index c42b9c6..c8ea4d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aitofy/browser-profiles", - "version": "0.2.11", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aitofy/browser-profiles", - "version": "0.2.11", + "version": "0.3.0", "license": "MIT", "dependencies": { "chrome-launcher": "^1.1.2", diff --git a/package.json b/package.json index d61cb8f..6892894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aitofy/browser-profiles", - "version": "0.2.12", + "version": "0.3.0", "description": "Self-hosted anti-detect browser profiles. Open-source AdsPower alternative for Puppeteer & Playwright.", "keywords": [ "antidetect", @@ -122,4 +122,4 @@ "url": "https://github.com/aitofy-dev/browser-profiles/issues" }, "sideEffects": false -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 383f9cc..c7d95b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -179,4 +179,4 @@ export type { } from './integrations/playwright'; // Version -export const VERSION = '0.2.12'; +export const VERSION = '0.3.0';