From c9016e16375ca45f98fcd6a288a498101d3279b8 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 12:30:46 +0700 Subject: [PATCH 1/6] feat(launcher): kernel engine and fixes for inject leaks Kernel engine: with a fingerprint-chromium binary the fingerprint is spoofed inside Chromium from a per-profile seed and no JavaScript hooks are installed. engine=auto picks it when the binary's switch table (the framework on macOS, chrome.dll on Windows) has fingerprint-platform. The launcher only sends Emulation.setLocaleOverride, because Chrome on macOS ignores --lang. Inject engine fixes, each with a regression test against real Chrome: - Intl reported the host locale; every tab gets setLocaleOverride. - Module, shared and service workers leaked the real machine; every worker target is paused, spoofed and resumed. - Accept-Language disagreed with navigator.languages. - webdriver, plugins, connection and getBattery were own properties of navigator; they now live on Navigator.prototype. Co-Authored-By: Claude Opus 5.5 --- docs/browser-lifecycle.md | 6 +- docs/cli.md | 9 +- docs/configuration.md | 5 +- docs/troubleshooting.md | 19 +++- src/commands/browser-launch.ts | 8 +- src/commands/browser-open.ts | 8 +- src/commands/fields.ts | 15 ++- src/fingerprint.ts | 84 +++++++++----- src/index.ts | 2 + src/integrations/playwright.ts | 10 +- src/integrations/puppeteer.ts | 62 +++++------ src/launcher/auto-attach.test.ts | 37 ++++++- src/launcher/auto-attach.ts | 18 ++- src/launcher/chrome-flags.ts | 5 +- src/launcher/chrome-path.ts | 23 +++- src/launcher/kernel.test.ts | 177 ++++++++++++++++++++++++++++++ src/launcher/kernel.ts | 183 +++++++++++++++++++++++++++++++ src/launcher/launch.e2e.test.ts | 141 +++++++++++++++++++++++- src/launcher/launch.ts | 49 +++++++-- src/launcher/protections.test.ts | 19 +++- src/launcher/protections.ts | 49 ++++++++- src/launcher/reuse.ts | 1 + src/launcher/standalone.ts | 11 ++ src/profile-manager.ts | 1 + src/storage.ts | 3 + src/types.ts | 17 +++ 26 files changed, 861 insertions(+), 101 deletions(-) create mode 100644 src/launcher/kernel.test.ts create mode 100644 src/launcher/kernel.ts diff --git a/docs/browser-lifecycle.md b/docs/browser-lifecycle.md index 608651f..efc0da8 100644 --- a/docs/browser-lifecycle.md +++ b/docs/browser-lifecycle.md @@ -59,8 +59,10 @@ in the library lets Chrome outlive the process that started it. That is the whol that does dies with its process. A detached launch with a proxy that has a username fails before Chrome starts, with an `INVALID_CONFIG` error, rather than leaving a browser that can never reach the network. A proxy without credentials is fine. -- **First-tab-only injection.** Nothing holds a CDP connection, so no new target can be paused and - injected. The first tab is protected; tabs opened later get only the flag-level protections. +- **First-tab-only injection** on the inject engine. Nothing holds a CDP connection, so no new + target can be paused and injected. The first tab is protected; tabs opened later get only the + flag-level protections. Kernel mode keeps the spoof on later tabs, because it is a process flag, + except the `Intl` locale on macOS. - **You close it yourself**, with `browser close` or `browser_close`, from any process. Use it for one-shot scripts that must exit immediately: diff --git a/docs/cli.md b/docs/cli.md index af572db..0e43547 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -142,9 +142,11 @@ profile returns the running browser with `reused=true`. Close it with `browser c |------|-------------| | `--headless` | Run Chrome without a visible window. Default false (a real window is far less detectable). Set true only on machines with no display. | | `--start-url ` | Absolute URL to open in the first tab, e.g. `"https://example.com"`. Omit for a blank tab. | -| `--detached` | Let Chrome outlive the process that opened it. Default false: the opener keeps the browser protected (per-tab fingerprint injection, authenticated proxy relay) and closes it on exit. Set true only for scripts that must exit immediately; then only flag-level protections remain and an authenticated proxy will not work. | +| `--detached` | Let Chrome outlive the process that opened it. Default false: the opener keeps the browser protected (per-tab fingerprint injection, authenticated proxy relay) and closes it on exit. Set true only for scripts that must exit immediately; then only flag-level protections remain and an authenticated proxy will not work. Kernel mode is the exception: its spoof is in the Chrome flags, so later tabs stay spoofed after the opener exits. | +| `--engine ` | Where the fingerprint is applied. `kernel` needs a fingerprint-chromium binary and spoofs inside Chromium with no JavaScript hooks. `inject` patches stock Chrome over CDP. `auto` (default) uses kernel when the executable contains `--fingerprint-platform`, otherwise inject. | -Output: `{ profileId, wsEndpoint, port, pid, reused, detached }`. +Output: `{ profileId, wsEndpoint, port, pid, reused, detached, engine }`. `engine` is `kernel` or +`inject`. ## browser launch @@ -158,8 +160,9 @@ when logins or cookies must survive. | `--headless` | As in `browser open`. | | `--no-random-fingerprint` | The schema defaults `randomFingerprint` to true (generate a random but internally consistent user agent, platform and hardware profile); this flag turns it off so Chrome's own fingerprint is used. | | `--detached` | As in `browser open`. | +| `--engine ` | As in `browser open`. | -Output: `{ profileId, wsEndpoint, port, pid, temporary: true, detached }`. The `profileId` is a +Output: `{ profileId, wsEndpoint, port, pid, temporary: true, detached, engine }`. The `profileId` is a `tmp-...` id. ## browser close diff --git a/docs/configuration.md b/docs/configuration.md index d50495c..0ca7b3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,7 +32,10 @@ Precedence, highest first: 1. `chromePath` — `new BrowserProfiles({ chromePath })` or `LaunchOptions.chromePath`. Used only if the file exists. 2. `CHROMIUM_PATH` or `CHROME_PATH` in the environment, again only if the file exists. -3. The platform's usual install locations. +3. A fingerprint-chromium kernel, if the file exists and contains `--fingerprint-platform`: + `~/.aitofy/browser-profiles/kernel/` (`Chromium.app` on macOS, `chrome` on Linux, `chrome.exe` + on Windows), then `Chromium.app` in `/Applications` or `~/Applications`. +4. The platform's usual install locations, Google Chrome first. If none of them resolves, the launch fails with `CHROME_NOT_FOUND`. See [troubleshooting.md](./troubleshooting.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index af2e4d6..014b150 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,8 +15,8 @@ Failed to open browser for "acme-main": Chrome/Chromium not found. Please instal ``` Error code `CHROME_NOT_FOUND`. The launcher looks at an explicit `chromePath`, then `CHROMIUM_PATH` -or `CHROME_PATH`, then the platform's usual install locations, and each candidate must exist on -disk. +or `CHROME_PATH`, then a fingerprint-chromium kernel, then the platform's usual install locations, +and each candidate must exist on disk. Install Google Chrome, or point at the binary you have: @@ -27,6 +27,21 @@ CHROME_PATH=/opt/google/chrome/chrome browser-profiles open acme-main From the library, pass it instead: `new BrowserProfiles({ chromePath })` or `profiles.launch(id, { chromePath })`. +## Kernel mode on stock Chrome + +``` +Failed to open browser for "acme-main": Kernel mode needs a fingerprint-chromium binary (engine-level spoof). This executable has no --fingerprint-platform switch, so the only spoof left would be JavaScript hooks, and those hooks are what browserscan and creepjs flag. Set CHROMIUM_PATH to a fingerprint-chromium build, or install the app at ~/.aitofy/browser-profiles/kernel/Chromium.app (macOS), ~/.aitofy/browser-profiles/kernel/chrome (Linux), or %USERPROFILE%\.aitofy\browser-profiles\kernel\chrome.exe (Windows). Builds: https://github.com/adryfish/fingerprint-chromium/releases +``` + +Error code `LAUNCH_FAILED`. `engine: "kernel"` was asked for, and the resolved executable is stock +Chrome. `engine: "auto"` (the default) does not throw: it falls back to JavaScript injection. + +Install a fingerprint-chromium build and point at it: + +```bash +CHROMIUM_PATH=/path/to/Chromium.app/Contents/MacOS/Chromium browser-profiles open acme-main --engine kernel +``` + ## Proxy failed ``` diff --git a/src/commands/browser-launch.ts b/src/commands/browser-launch.ts index cbd2691..98d92a4 100644 --- a/src/commands/browser-launch.ts +++ b/src/commands/browser-launch.ts @@ -6,7 +6,7 @@ import { generateFingerprint } from '../fingerprint'; import { defineCommand } from './define'; import { launchErrorCode } from './browser-open'; import { newTempProfileId, tempSessionDir } from './browser-runtime'; -import { detachedField, headlessField, optionalProxyUrlField } from './fields'; +import { detachedField, engineField, headlessField, optionalProxyUrlField } from './fields'; import { parseProxyUrl } from './proxy-url'; export interface BrowserLaunched { @@ -17,6 +17,8 @@ export interface BrowserLaunched { temporary: true; /** True when Chrome was started to outlive the opener (reduced protection). */ detached: boolean; + /** kernel spoofs inside Chromium. inject patches from JavaScript. */ + engine: 'kernel' | 'inject'; } export const browserLaunch = defineCommand({ @@ -37,6 +39,7 @@ export const browserLaunch = defineCommand({ 'Default true. Set false to use Chrome own fingerprint.' ), detached: detachedField, + engine: engineField, }), async run(ctx, input): Promise> { let proxy: ProxyConfig | undefined; @@ -56,6 +59,7 @@ export const browserLaunch = defineCommand({ headless: input.headless ?? false, proxy, detached: input.detached ?? false, + engine: input.engine, fingerprint: fingerprint && { userAgent: fingerprint.userAgent, language: fingerprint.language, @@ -73,6 +77,7 @@ export const browserLaunch = defineCommand({ pid: result.pid, temporary: true, detached: input.detached ?? false, + engine: result.engine, }); } catch (thrown) { const cause = thrown instanceof Error ? thrown : new Error(String(thrown)); @@ -90,6 +95,7 @@ export const browserLaunch = defineCommand({ ` wsEndpoint: ${output.wsEndpoint}`, ` port: ${output.port}`, ` pid: ${output.pid}`, + ` engine: ${output.engine}`, '', `Close with: browser-profiles browser close ${output.profileId}`, '', diff --git a/src/commands/browser-open.ts b/src/commands/browser-open.ts index b2cb4d7..daa984a 100644 --- a/src/commands/browser-open.ts +++ b/src/commands/browser-open.ts @@ -3,7 +3,7 @@ import { Err, Ok } from '../types'; import type { BrowserError, Result } from '../types'; import { DETACHED_PROXY_MESSAGE } from '../launcher/proxy'; import { defineCommand, resolveProfile } from './define'; -import { detachedField, headlessField, idOrNameField } from './fields'; +import { detachedField, engineField, headlessField, idOrNameField } from './fields'; export interface BrowserOpened { profileId: string; @@ -14,6 +14,8 @@ export interface BrowserOpened { reused: boolean; /** True when Chrome was started to outlive the opener (reduced protection). */ detached: boolean; + /** kernel spoofs inside Chromium. inject patches from JavaScript. */ + engine: 'kernel' | 'inject'; } /** The launcher reports every problem as a thrown Error; keep the codes honest. */ @@ -44,6 +46,7 @@ export const browserOpen = defineCommand({ .optional() .describe('Absolute URL to open in the first tab, e.g. "https://example.com". Omit for a blank tab.'), detached: detachedField, + engine: engineField, }), async run(ctx, input): Promise> { const found = await resolveProfile(ctx, input.idOrName); @@ -54,6 +57,7 @@ export const browserOpen = defineCommand({ const result = await ctx.profiles.launch(profile.id, { headless: input.headless ?? false, detached: input.detached ?? false, + engine: input.engine, args: input.startUrl ? [input.startUrl] : [], }); @@ -64,6 +68,7 @@ export const browserOpen = defineCommand({ pid: result.pid, reused: result.reused === true, detached: input.detached ?? false, + engine: result.engine, }); } catch (thrown) { return Err(launchError(profile.id, thrown)); @@ -77,6 +82,7 @@ export const browserOpen = defineCommand({ ` wsEndpoint: ${output.wsEndpoint}`, ` port: ${output.port}`, ` pid: ${output.pid}`, + ` engine: ${output.engine}`, '', `Close with: browser-profiles browser close ${output.profileId}`, '', diff --git a/src/commands/fields.ts b/src/commands/fields.ts index 873bfe5..a243aa6 100644 --- a/src/commands/fields.ts +++ b/src/commands/fields.ts @@ -82,5 +82,18 @@ export const detachedField = z 'Let Chrome outlive the process that opened it. Default false: the opener keeps the browser ' + 'protected (per-tab fingerprint injection, authenticated proxy relay) and closes it on exit. ' + 'Set true only for scripts that must exit immediately; then only flag-level protections remain ' + - 'and an authenticated proxy will not work.' + 'and an authenticated proxy will not work. Kernel mode is the exception: its spoof is in the ' + + 'Chrome flags, so later tabs stay spoofed after the opener exits.' + ); + +export const engineField = z + .enum(['auto', 'kernel', 'inject']) + .optional() + .describe( + 'Where the fingerprint is applied. "kernel" needs a fingerprint-chromium binary and spoofs ' + + 'canvas, WebGL, audio, fonts and client rects inside Chromium, with no JavaScript hooks. ' + + '"inject" patches stock Chrome from JavaScript over CDP. "auto" (default) uses kernel when the ' + + 'binary has the fingerprint-platform switch, otherwise inject. Install ' + + 'the kernel at ~/.aitofy/browser-profiles/kernel/ or set CHROMIUM_PATH. ' + + 'Builds: https://github.com/adryfish/fingerprint-chromium/releases' ); diff --git a/src/fingerprint.ts b/src/fingerprint.ts index ce4585c..98a9247 100644 --- a/src/fingerprint.ts +++ b/src/fingerprint.ts @@ -419,6 +419,11 @@ export function createNavigatorScript(config: { replaceGetter('languages', [spoofedProps.language, spoofedProps.language.split('-')[0]]); } + if (spoofedProps.userAgent) { + replaceGetter('userAgent', spoofedProps.userAgent); + replaceGetter('appVersion', spoofedProps.userAgent.replace('Mozilla/', '')); + } + if (spoofedProps.platform) { replaceGetter('platform', spoofedProps.platform); } @@ -504,15 +509,29 @@ export function getProfileProtectionScripts(fingerprint?: FingerprintConfig): st webgl: fingerprint?.webgl ?? true, audio: true, workers: true, - navigator: { - language: fingerprint?.language || FINGERPRINT_DEFAULTS.language, - platform: fingerprint?.platform || FINGERPRINT_DEFAULTS.platform, - hardwareConcurrency: fingerprint?.hardwareConcurrency || FINGERPRINT_DEFAULTS.hardwareConcurrency, - deviceMemory: fingerprint?.deviceMemory || FINGERPRINT_DEFAULTS.deviceMemory, - }, + navigator: profileNavigator(fingerprint), }); } +/** + * The navigator/WebGL spoof for a worker target, evaluated over CDP before the + * worker's own script runs. Shared and service workers keep the real UA string + * whatever CDP overrides, so `userAgent` is set from JavaScript too. + */ +export function getProfileWorkerScript(fingerprint: FingerprintConfig | undefined, userAgent: string): string { + const navigatorScript = createNavigatorScript({ ...profileNavigator(fingerprint), userAgent }); + return [navigatorScript, createWebGLScript(fingerprint?.webgl)].join('\n\n'); +} + +function profileNavigator(fingerprint?: FingerprintConfig) { + return { + language: fingerprint?.language || FINGERPRINT_DEFAULTS.language, + platform: fingerprint?.platform || FINGERPRINT_DEFAULTS.platform, + hardwareConcurrency: fingerprint?.hardwareConcurrency || FINGERPRINT_DEFAULTS.hardwareConcurrency, + deviceMemory: fingerprint?.deviceMemory || FINGERPRINT_DEFAULTS.deviceMemory, + }; +} + /** * Pick a WebGL vendor/renderer that matches a navigator.platform value */ @@ -577,17 +596,16 @@ export const AUTOMATION_BYPASS_SCRIPT = ` }); // ===== WEBDRIVER REMOVAL ===== - // Remove webdriver flag - Object.defineProperty(navigator, 'webdriver', { - get: () => false, // Return false instead of undefined (more natural) - configurable: true - }); - - // Remove automation-related properties from navigator prototype - try { - delete Object.getPrototypeOf(navigator).webdriver; - } catch {} - + // Real Chrome keeps webdriver on Navigator.prototype and no own property on navigator; + // detectors check both, so only the prototype getter is replaced, and only when it says true. + if (navigator.webdriver) { + Object.defineProperty(Object.getPrototypeOf(navigator), 'webdriver', { + get: () => false, + configurable: true, + enumerable: true + }); + } + // ===== CHROME OBJECT FIX ===== if (!window.chrome) { window.chrome = {}; @@ -686,13 +704,16 @@ export const AUTOMATION_BYPASS_SCRIPT = ` fakePlugins.namedItem = (name) => fakePlugins.find(p => p.name === name); fakePlugins.refresh = () => {}; - Object.defineProperty(navigator, 'plugins', { + // Overrides live on Navigator.prototype: real Chrome has no own properties on navigator. + Object.defineProperty(Object.getPrototypeOf(navigator), 'plugins', { get: () => fakePlugins, - configurable: true + configurable: true, + enumerable: true }); // ===== CONNECTION API ===== - Object.defineProperty(navigator, 'connection', { + Object.defineProperty(Object.getPrototypeOf(navigator), 'connection', { + enumerable: true, get: () => ({ effectiveType: '4g', rtt: 50, @@ -705,15 +726,20 @@ export const AUTOMATION_BYPASS_SCRIPT = ` }); // ===== BATTERY API ===== - navigator.getBattery = () => Promise.resolve({ - charging: true, - chargingTime: 0, - dischargingTime: Infinity, - level: 0.95 + Math.random() * 0.05, // Slight variation - onchargingchange: null, - onchargingtimechange: null, - ondischargingtimechange: null, - onlevelchange: null + Object.defineProperty(Object.getPrototypeOf(navigator), 'getBattery', { + value: () => Promise.resolve({ + charging: true, + chargingTime: 0, + dischargingTime: Infinity, + level: 0.95 + Math.random() * 0.05, // Slight variation + onchargingchange: null, + onchargingtimechange: null, + ondischargingtimechange: null, + onlevelchange: null + }), + configurable: true, + enumerable: true, + writable: true }); // ===== MEDIA DEVICES ===== diff --git a/src/index.ts b/src/index.ts index ecd759b..d64858b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,6 +118,8 @@ export type { // Launch types LaunchOptions, LaunchResult, + FingerprintEngine, + ResolvedEngine, // Proxy & Cookie types ProxyConfig, diff --git a/src/integrations/playwright.ts b/src/integrations/playwright.ts index 664751f..3a40773 100644 --- a/src/integrations/playwright.ts +++ b/src/integrations/playwright.ts @@ -173,6 +173,7 @@ export async function withPlaywright(options: WithPlaywrightOptions): Promise 0 ? contexts[0] : await browser.newContext(); - await applyProtection(context, profile.fingerprint); + if (launch.engine !== 'kernel') await applyProtection(context, profile.fingerprint); const pages = context.pages(); page = pages.length > 0 ? pages[0] : await context.newPage(); } @@ -244,6 +245,7 @@ export async function quickLaunchPlaywright(options: QuickLaunchPlaywrightOption defaultViewport: options.defaultViewport, slowMo: options.slowMo, timeout: options.timeout, + engine: options.engine, }); const browser: PlaywrightBrowser = await playwright.chromium.connectOverCDP(launch.wsEndpoint); @@ -257,12 +259,12 @@ export async function quickLaunchPlaywright(options: QuickLaunchPlaywrightOption timezoneId: options.timezone || 'America/New_York', viewport: options.defaultViewport || null, }); - await applyProtection(context, profile.fingerprint); + if (launch.engine !== 'kernel') 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); + if (launch.engine !== 'kernel') 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 515e168..1107509 100644 --- a/src/integrations/puppeteer.ts +++ b/src/integrations/puppeteer.ts @@ -272,6 +272,7 @@ export async function withPuppeteer(options: WithPuppeteerOptions): Promise 0 ? pages[0] : await browser.newPage(); + // CDP script injection is per-session, so scripts installed by the launcher // on its own CDP client do NOT reach pages driven by this Puppeteer - // connection. Re-injecting here is what actually protects the page. - const bundle = getProfileProtectionScripts(profile.fingerprint); - const injectProtectionScripts = async (page: PuppeteerPage) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(bundle); - }; - - // Listen for new pages and inject scripts (like puppeteer-extra-stealth's onPageCreated) - browser.on('targetcreated', async (target: any) => { - if (target.type() === 'page') { - try { - const newPage = await target.page(); - if (newPage) { - await injectProtectionScripts(newPage); + // connection. Re-injecting here is what actually protects an inject-mode page. + // Kernel mode already spoofed inside Chromium; a hook here is the tell. + if (launch.engine !== 'kernel') { + const bundle = getProfileProtectionScripts(profile.fingerprint); + const injectProtectionScripts = async (targetPage: PuppeteerPage) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (targetPage as any).evaluateOnNewDocument(bundle); + }; + + browser.on('targetcreated', async (target: any) => { + if (target.type() === 'page') { + try { + const newPage = await target.page(); + if (newPage) await injectProtectionScripts(newPage); + } catch { + // Ignore errors for pages that can't be accessed } - } catch { - // Ignore errors for pages that can't be accessed } - } - }); - - // Reuse existing page or create new one if needed - // This prevents duplicate empty pages from appearing - const pages = await browser.pages(); - const page = pages.length > 0 ? pages[0] : await browser.newPage(); - - // Inject scripts into the page - await injectProtectionScripts(page); - // Note: evaluateOnNewDocument scripts will run on first user navigation + }); + await injectProtectionScripts(page); + } // Close function - by default only closes this session's page const close = async (options?: CloseOptions) => { @@ -389,6 +386,7 @@ export async function quickLaunch(options: QuickLaunchOptions = {}): Promise 0 ? pages[0] : await browser.newPage(); - // Inject the full anti-detect bundle (navigator, WebGL, workers, WebRTC, - // automation bypass) via Puppeteer's native API. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (page as any).evaluateOnNewDocument(getProfileProtectionScripts(profile.fingerprint)); + if (launch.engine !== 'kernel') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (page as any).evaluateOnNewDocument(getProfileProtectionScripts(profile.fingerprint)); + } // Close function - by default only closes this session's page const close = async (closeOptions?: CloseOptions) => { diff --git a/src/launcher/auto-attach.test.ts b/src/launcher/auto-attach.test.ts index f925ee3..bca1afb 100644 --- a/src/launcher/auto-attach.test.ts +++ b/src/launcher/auto-attach.test.ts @@ -117,6 +117,7 @@ describe('startAutoAttach', () => { 'Page.enable', 'Page.addScriptToEvaluateOnNewDocument', 'Emulation.setTimezoneOverride', + 'Emulation.setLocaleOverride', 'Runtime.evaluate', 'Target.setAutoAttach', 'Runtime.runIfWaitingForDebugger', @@ -171,11 +172,45 @@ describe('startAutoAttach', () => { expect(client.methods('s1')).toContain('Runtime.runIfWaitingForDebugger'); }); + it('spoofs a worker before it is resumed', async () => { + const client = new FakeClient(); + const handle = await start(client); + + client.attach('s1', 'w1', 'shared_worker'); + await handle.settled(); + + expect(client.methods('s1')).toEqual([ + 'Network.setUserAgentOverride', + 'Runtime.evaluate', + 'Runtime.runIfWaitingForDebugger', + ]); + const evaluated = client.sent.find((call) => call.method === 'Runtime.evaluate'); + expect(evaluated?.params).toEqual({ expression: buildProtectionPlan(profile).workerScript }); + expect(handle.protectedTargets()).toEqual(['w1']); + }); + + it('gives a kernel page only the locale override, and leaves its workers alone', async () => { + const client = new FakeClient(); + const plan = buildProtectionPlan(profile, 'Europe/Paris', { major: 152, full: '152.0.0.0' }, 'kernel'); + const handle = await startAutoAttach({ client, plan, log }); + + client.attach('s1', 't1'); + client.attach('s2', 'w1', 'worker'); + await handle.settled(); + + expect(client.methods('s1')).toEqual([ + 'Emulation.setLocaleOverride', + 'Target.setAutoAttach', + 'Runtime.runIfWaitingForDebugger', + ]); + expect(client.methods('s2')).toEqual(['Runtime.runIfWaitingForDebugger']); + }); + it('resumes targets it does not protect', async () => { const client = new FakeClient(); const handle = await start(client); - client.attach('s1', 't1', 'service_worker'); + client.attach('s1', 't1', 'other'); await handle.settled(); expect(client.methods('s1')).toEqual(['Runtime.runIfWaitingForDebugger']); diff --git a/src/launcher/auto-attach.ts b/src/launcher/auto-attach.ts index 4babd39..db65660 100644 --- a/src/launcher/auto-attach.ts +++ b/src/launcher/auto-attach.ts @@ -4,7 +4,7 @@ import type { Logger } from '../log'; import type { CdpClient, CdpParams } from './deps'; -import { applyProtections, sessionOf } from './protections'; +import { applyProtections, applyWorkerProtections, sessionOf } from './protections'; import type { ProtectionPlan } from './protections'; /** Pausing new targets is what lets us inject before their first script runs. */ @@ -15,6 +15,7 @@ const AUTO_ATTACH_PARAMS: CdpParams = { }; const PROTECTED_TYPES = new Set(['page', 'iframe']); +const WORKER_TYPES = new Set(['worker', 'shared_worker', 'service_worker']); interface AttachedTarget { sessionId: string; @@ -65,15 +66,24 @@ export async function startAutoAttach(options: AutoAttachOptions): Promise => { let patched: Promise = Promise.resolve(); try { - if (stopped || !PROTECTED_TYPES.has(target.type) || isProtected(target.targetId)) return; - sessions.set(target.sessionId, target.targetId); + if (stopped || isProtected(target.targetId)) return; const session = sessionOf(client, target.sessionId); + if (WORKER_TYPES.has(target.type)) { + sessions.set(target.sessionId, target.targetId); + await applyWorkerProtections(session, plan); + log.debug(`Protected ${target.type} target ${target.targetId}`); + return; + } + if (!PROTECTED_TYPES.has(target.type)) return; + sessions.set(target.sessionId, target.targetId); await applyProtections(session, plan); // A popup's first document is already committed when we attach, so the // new-document script above would only reach its next navigation. This patches // the document that exists now; it is only awaited after the resume below, // because evaluating needs the renderer thread the pause is holding. - patched = session.send('Runtime.evaluate', { expression: plan.initScript }).catch(() => undefined); + if (plan.initScript) { + patched = session.send('Runtime.evaluate', { expression: plan.initScript }).catch(() => undefined); + } if (target.type === 'page') { // Out-of-process iframes are only reported to a session that asks for them. await client.send('Target.setAutoAttach', AUTO_ATTACH_PARAMS, target.sessionId); diff --git a/src/launcher/chrome-flags.ts b/src/launcher/chrome-flags.ts index 0e35801..154d8d3 100644 --- a/src/launcher/chrome-flags.ts +++ b/src/launcher/chrome-flags.ts @@ -15,6 +15,8 @@ export interface ChromeFlagOptions { extensions: string[]; /** Local relay URL from `startProxyRelay`, never the upstream proxy. */ proxyServer?: string; + /** fingerprint-chromium switches. Empty on the inject engine. */ + kernelFlags?: string[]; } const BASE_FLAGS = [ @@ -58,12 +60,13 @@ function extensionFlags(extensions: string[]): string[] { } export function buildChromeFlags(options: ChromeFlagOptions): string[] { - const { profile, userDataDir, headless, args, extensions, proxyServer } = options; + const { profile, userDataDir, headless, args, extensions, proxyServer, kernelFlags = [] } = options; return [ ...BASE_FLAGS, `--lang=${profile.fingerprint?.language || 'en-US'}`, ...ANTI_DETECT_FLAGS, + ...kernelFlags, ...(userDataDir ? [`--user-data-dir=${userDataDir}`] : []), ...(headless ? HEADLESS_FLAGS : []), ...(proxyServer ? [`--proxy-server=${proxyServer}`] : []), diff --git a/src/launcher/chrome-path.ts b/src/launcher/chrome-path.ts index debb8b3..de10233 100644 --- a/src/launcher/chrome-path.ts +++ b/src/launcher/chrome-path.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { binaryLooksLikeKernel } from './kernel'; const MAC_PATHS = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', @@ -43,9 +44,25 @@ function platformCandidates(): string[] { } } +/** fingerprint-chromium installs. Used only when the file contains the kernel switch. */ +export function kernelBinaryCandidates(): string[] { + const root = path.join(os.homedir(), '.aitofy', 'browser-profiles', 'kernel'); + const bundled = [ + path.join(root, 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), + path.join(root, 'chrome'), + path.join(root, 'chrome.exe'), + ]; + const installed = [ + '/Applications/Chromium.app/Contents/MacOS/Chromium', + path.join(os.homedir(), 'Applications/Chromium.app/Contents/MacOS/Chromium'), + ]; + return [...bundled, ...installed]; +} + /** * Resolve the Chrome/Chromium executable. - * Precedence: explicit path > CHROMIUM_PATH/CHROME_PATH > platform defaults. + * Precedence: explicit path > CHROMIUM_PATH/CHROME_PATH > a fingerprint-chromium + * kernel > platform defaults (stock Google Chrome). */ export function getChromePath(customPath?: string): string { if (customPath && fs.existsSync(customPath)) return customPath; @@ -53,6 +70,10 @@ export function getChromePath(customPath?: string): string { const envPath = process.env.CHROMIUM_PATH || process.env.CHROME_PATH; if (envPath && fs.existsSync(envPath)) return envPath; + for (const candidate of kernelBinaryCandidates()) { + if (fs.existsSync(candidate) && binaryLooksLikeKernel(candidate)) return candidate; + } + for (const candidate of platformCandidates()) { if (fs.existsSync(candidate)) return candidate; } diff --git a/src/launcher/kernel.test.ts b/src/launcher/kernel.test.ts new file mode 100644 index 0000000..eceb5df --- /dev/null +++ b/src/launcher/kernel.test.ts @@ -0,0 +1,177 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { StoredProfile } from '../types'; +import { buildChromeFlags } from './chrome-flags'; +import { kernelBinaryCandidates } from './chrome-path'; +import { + KERNEL_REQUIRED_MESSAGE, + KERNEL_SWITCH, + binaryLooksLikeKernel, + buildKernelFlags, + kernelPlatform, + kernelSeed, + resolveEngine, +} from './kernel'; + +const dirs: string[] = []; + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kernel-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const profile: StoredProfile = { + id: 'profile-a', + name: 'A', + createdAt: 0, + updatedAt: 0, + fingerprint: { + platform: 'MacIntel', + language: 'vi-VN', + hardwareConcurrency: 12, + canvas: 'real', + }, +}; + +describe('kernelSeed', () => { + it('stays inside the positive 31-bit range and follows the profile id', () => { + const seed = kernelSeed('profile-a'); + expect(seed).toBe(kernelSeed('profile-a')); + expect(seed).not.toBe(kernelSeed('profile-b')); + expect(seed).toBeGreaterThan(0); + expect(seed).toBeLessThan(2_147_483_647); + }); +}); + +describe('kernelPlatform', () => { + it('maps navigator.platform onto the kernel os names', () => { + expect(kernelPlatform('Win32')).toBe('windows'); + expect(kernelPlatform('MacIntel')).toBe('macos'); + expect(kernelPlatform('Linux x86_64')).toBe('linux'); + }); +}); + +describe('buildKernelFlags', () => { + it('derives a Chrome identity from the profile and leaves the brand version to the binary', () => { + const flags = buildKernelFlags(profile, 'Asia/Ho_Chi_Minh'); + expect(flags).toContain(`--fingerprint=${kernelSeed('profile-a')}`); + expect(flags).toContain('--fingerprint-platform=macos'); + expect(flags).toContain('--fingerprint-brand=Chrome'); + expect(flags.some((flag) => flag.includes('fingerprint-brand-version'))).toBe(false); + expect(flags).toContain('--fingerprint-hardware-concurrency=12'); + expect(flags).toContain('--accept-lang=vi-VN,vi'); + expect(flags).toContain('--timezone=Asia/Ho_Chi_Minh'); + expect(flags).toContain('--disable-non-proxied-udp'); + expect(flags).toContain('--disable-spoofing=canvas'); + }); + + it('omits cores and disable-spoofing when the profile leaves them unset', () => { + const flags = buildKernelFlags( + { id: 'bare', name: 'Bare', createdAt: 0, updatedAt: 0 }, + 'America/New_York' + ); + expect(flags.some((flag) => flag.startsWith('--fingerprint-hardware-concurrency'))).toBe(false); + expect(flags.some((flag) => flag.startsWith('--disable-spoofing'))).toBe(false); + expect(flags).toContain('--fingerprint-platform=windows'); + expect(flags).toContain('--accept-lang=en-US,en'); + }); + + it('is placed before a caller flag, so the caller can override it', () => { + const flags = buildChromeFlags({ + profile, + userDataDir: '/tmp/profile', + headless: false, + args: ['--fingerprint=1'], + extensions: [], + kernelFlags: buildKernelFlags(profile, 'Asia/Ho_Chi_Minh'), + }); + expect(flags.at(-1)).toBe('--fingerprint=1'); + expect(flags.some((flag) => flag.startsWith('--fingerprint='))).toBe(true); + }); +}); + +describe('resolveEngine', () => { + it('uses the kernel only when the binary has the switch', () => { + expect(resolveEngine('auto', true)).toBe('kernel'); + expect(resolveEngine('auto', false)).toBe('inject'); + expect(resolveEngine('inject', true)).toBe('inject'); + expect(resolveEngine('kernel', true)).toBe('kernel'); + }); + + it('refuses kernel mode on stock Chrome', () => { + expect(() => resolveEngine('kernel', false)).toThrow(KERNEL_REQUIRED_MESSAGE); + }); +}); + +// Chromium compiles switch names without their dashes. +const SWITCH_NAME = KERNEL_SWITCH.replace(/^--/, ''); + +function writeFile(file: string, content: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +describe('binaryLooksLikeKernel', () => { + it('finds the switch even when it straddles a read boundary', () => { + const dir = tempDir(); + const file = path.join(dir, 'chrome'); + const markerAt = 1024 * 1024 - 10; + const body = Buffer.alloc(markerAt + SWITCH_NAME.length, 0x61); + body.write(SWITCH_NAME, markerAt, 'utf8'); + fs.writeFileSync(file, body); + + expect(binaryLooksLikeKernel(file)).toBe(true); + expect(binaryLooksLikeKernel(file)).toBe(true); + }); + + it('rejects a binary that merely mentions fingerprint', () => { + const dir = tempDir(); + const file = path.join(dir, 'chrome'); + fs.writeFileSync(file, Buffer.from('--fingerprint=1 --fingerprint-brand=Chrome')); + expect(binaryLooksLikeKernel(file)).toBe(false); + }); + + it('reads the macOS framework, not the launcher stub', () => { + const app = path.join(tempDir(), 'Chromium.app'); + const stub = path.join(app, 'Contents', 'MacOS', 'Chromium'); + writeFile(stub, 'launcher stub'); + const versions = path.join(app, 'Contents', 'Frameworks', 'Chromium Framework.framework', 'Versions'); + writeFile(path.join(versions, '148.0.0.0', 'Chromium Framework'), `switches ${SWITCH_NAME} end`); + fs.symlinkSync('148.0.0.0', path.join(versions, 'Current')); + + expect(binaryLooksLikeKernel(stub)).toBe(true); + }); + + it('rejects a stock macOS app whose framework lacks the switch', () => { + const app = path.join(tempDir(), 'Google Chrome.app'); + const stub = path.join(app, 'Contents', 'MacOS', 'Google Chrome'); + writeFile(stub, `stub mentioning ${SWITCH_NAME}`); + const framework = path.join(app, 'Contents', 'Frameworks', 'Google Chrome Framework.framework'); + writeFile(path.join(framework, 'Versions', 'Current', 'Google Chrome Framework'), 'remote-debugging-port'); + + expect(binaryLooksLikeKernel(stub)).toBe(false); + }); + + it('reads chrome.dll next to a Windows launcher', () => { + const dir = tempDir(); + const exe = path.join(dir, 'chrome.exe'); + writeFile(exe, 'launcher stub'); + writeFile(path.join(dir, '148.0.0.0', 'chrome.dll'), `switches ${SWITCH_NAME} end`); + + expect(binaryLooksLikeKernel(exe)).toBe(true); + }); +}); + +describe('kernelBinaryCandidates', () => { + it('looks in the user kernel directory before a stock install', () => { + const candidates = kernelBinaryCandidates(); + expect(candidates[0]).toContain(`${path.join('.aitofy', 'browser-profiles', 'kernel')}`); + }); +}); diff --git a/src/launcher/kernel.ts b/src/launcher/kernel.ts new file mode 100644 index 0000000..6073b3c --- /dev/null +++ b/src/launcher/kernel.ts @@ -0,0 +1,183 @@ +// ============================================================================ +// @aitofy/browser-profiles - Kernel fingerprint mode +// ============================================================================ +// fingerprint-chromium (an open engine-level spoof) +// applies canvas, WebGL, audio, fonts, client rects and webdriver inside Blink +// when --fingerprint is set. JavaScript hooks are not added on top: detectors +// flag the hooks, not the values. + +import fs from 'fs'; +import path from 'path'; +import type { FingerprintConfig, StoredProfile } from '../types'; +import type { FingerprintEngine, ResolvedEngine } from '../types'; +import { FINGERPRINT_DEFAULTS } from '../fingerprint'; + +export const KERNEL_SWITCH = '--fingerprint-platform'; + +export const KERNEL_REQUIRED_MESSAGE = + 'Kernel mode needs a fingerprint-chromium binary (engine-level spoof). ' + + 'This executable has no --fingerprint-platform switch, so the only spoof left would be JavaScript ' + + 'hooks, and those hooks are what browserscan and creepjs flag. ' + + 'Set CHROMIUM_PATH to a fingerprint-chromium build, or install the app at ' + + '~/.aitofy/browser-profiles/kernel/Chromium.app (macOS), ' + + '~/.aitofy/browser-profiles/kernel/chrome (Linux), ' + + 'or %USERPROFILE%\\.aitofy\\browser-profiles\\kernel\\chrome.exe (Windows). ' + + 'Builds: https://github.com/adryfish/fingerprint-chromium/releases'; + +const CHUNK_BYTES = 1024 * 1024; +// Chromium stores switch names without the leading dashes. +const MARKER = Buffer.from(KERNEL_SWITCH.replace(/^--/, '')); + +interface ScanCacheEntry { + mtimeMs: number; + size: number; + kernel: boolean; +} + +const scanCache = new Map(); + +/** True when the executable is a fingerprint-chromium build, not stock Chrome. */ +export function binaryLooksLikeKernel(executablePath: string): boolean { + return switchTableFiles(executablePath).some(fileHasMarker); +} + +/** + * The file that holds Chromium's switch names. On macOS and Windows the + * executable is a small launcher; the switches live in the framework or chrome.dll. + */ +export function switchTableFiles(executablePath: string): string[] { + const macApp = executablePath.match(/^(.*\.app)\/Contents\/MacOS\/[^/]+$/); + if (macApp) { + const frameworks = path.join(macApp[1], 'Contents', 'Frameworks'); + return listDir(frameworks) + .filter((name) => name.endsWith(' Framework.framework')) + .map((name) => path.join(frameworks, name, 'Versions', 'Current', name.replace(/\.framework$/, ''))); + } + if (executablePath.toLowerCase().endsWith('.exe')) { + const dir = path.dirname(executablePath); + return listDir(dir).map((name) => path.join(dir, name, 'chrome.dll')); + } + return [executablePath]; +} + +function listDir(dir: string): string[] { + try { + return fs.readdirSync(dir); + } catch { + return []; + } +} + +function fileHasMarker(filePath: string): boolean { + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return false; + } + if (!stat.isFile()) return false; + + const cached = scanCache.get(filePath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.kernel; + + const kernel = fileContains(filePath, stat.size); + scanCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, kernel }); + return kernel; +} + +function fileContains(filePath: string, size: number): boolean { + const fd = fs.openSync(filePath, 'r'); + try { + const buf = Buffer.alloc(CHUNK_BYTES); + let position = 0; + let carry = Buffer.alloc(0); + const overlap = MARKER.length - 1; + while (position < size) { + const read = fs.readSync(fd, buf, 0, CHUNK_BYTES, position); + if (read <= 0) break; + const window = carry.length === 0 + ? buf.subarray(0, read) + : Buffer.concat([carry, buf.subarray(0, read)]); + if (window.includes(MARKER)) return true; + carry = Buffer.from(window.subarray(Math.max(0, window.length - overlap))); + position += read; + } + return false; + } finally { + fs.closeSync(fd); + } +} + +/** + * auto uses the kernel only when this executable actually has the switch. + * kernel without the switch throws: skipping the hooks on stock Chrome would + * launch a browser with no spoof at all. + */ +export function resolveEngine(requested: FingerprintEngine, binaryIsKernel: boolean): ResolvedEngine { + if (requested === 'inject') return 'inject'; + if (requested === 'kernel') { + if (!binaryIsKernel) throw new Error(KERNEL_REQUIRED_MESSAGE); + return 'kernel'; + } + return binaryIsKernel ? 'kernel' : 'inject'; +} + +/** Stable per profile. A new id (including a duplicate) is a new fingerprint. */ +export function kernelSeed(profileId: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < profileId.length; i++) { + hash ^= profileId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const positive = (hash >>> 0) % 2_147_483_647; + return positive === 0 ? 1 : positive; +} + +export function kernelPlatform(navigatorPlatform: string): 'windows' | 'macos' | 'linux' { + const value = navigatorPlatform.toLowerCase(); + if (value.includes('mac')) return 'macos'; + if (value.includes('linux') || value === 'x11') return 'linux'; + return 'windows'; +} + +function acceptLanguage(language: string): string { + const primary = language.split('-')[0]; + if (!primary || primary.toLowerCase() === language.toLowerCase()) return language; + return `${language},${primary}`; +} + +function disabledSurfaces(fingerprint: FingerprintConfig | undefined): string[] { + const off: string[] = []; + if (fingerprint?.canvas === 'real') off.push('canvas'); + if (fingerprint?.audio === 'real') off.push('audio'); + return off; +} + +/** + * Flags the kernel build understands. Brand version is left unset so the UA + * matches the binary; a pinned fingerprint.userAgent is not applied. + * GPU strings are derived from the seed (Chrome 144 removed the vendor flags). + */ +export function buildKernelFlags(profile: StoredProfile, timezone: string): string[] { + const fingerprint = profile.fingerprint; + const language = fingerprint?.language || FINGERPRINT_DEFAULTS.language; + const platform = kernelPlatform(fingerprint?.platform || FINGERPRINT_DEFAULTS.platform); + const flags = [ + `--fingerprint=${kernelSeed(profile.id)}`, + `--fingerprint-platform=${platform}`, + '--fingerprint-brand=Chrome', + `--accept-lang=${acceptLanguage(language)}`, + `--timezone=${timezone}`, + '--disable-non-proxied-udp', + ]; + + const cores = fingerprint?.hardwareConcurrency; + if (typeof cores === 'number' && Number.isInteger(cores) && cores > 0) { + flags.push(`--fingerprint-hardware-concurrency=${cores}`); + } + + const off = disabledSurfaces(fingerprint); + if (off.length > 0) flags.push(`--disable-spoofing=${off.join(',')}`); + + return flags; +} diff --git a/src/launcher/launch.e2e.test.ts b/src/launcher/launch.e2e.test.ts index 9533ada..3a930ab 100644 --- a/src/launcher/launch.e2e.test.ts +++ b/src/launcher/launch.e2e.test.ts @@ -55,6 +55,48 @@ async function servePages(): Promise<{ url: string; reported: () => Promise ({ + platform: navigator.platform, + cores: navigator.hardwareConcurrency, + language: navigator.language, + locale: Intl.DateTimeFormat().resolvedOptions().locale, + userAgent: navigator.userAgent, + appVersion: navigator.appVersion, +}); +if (typeof ServiceWorkerGlobalScope !== 'undefined') { + clients.matchAll({ includeUncontrolled: true }).then((all) => all.forEach((c) => c.postMessage(probe()))); +} else if (typeof onconnect !== 'undefined') onconnect = (event) => event.ports[0].postMessage(probe()); +else postMessage(probe()); +`; + +/** Serves a page that starts every worker kind from a real URL. */ +async function serveWorkerPage(): Promise<{ url: string; acceptLanguage: () => string; close: () => Promise }> { + let acceptLanguage = ''; + const server = http.createServer((request, response) => { + acceptLanguage = request.headers['accept-language'] ?? ''; + if (request.url === '/probe.js') { + response.writeHead(200, { 'content-type': 'text/javascript' }); + response.end(WORKER_PROBE); + return; + } + response.writeHead(200, { 'content-type': 'text/html' }); + response.end('workers'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/`, + acceptLanguage: () => acceptLanguage, + close: () => new Promise((resolve) => { + // A shared worker can outlive the page and keep its connection open. + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + let launched: LaunchResult | null = null; let userDataDir = ''; @@ -82,12 +124,17 @@ e2e('a browser launched for a profile', () => { platform: navigator.platform, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, webdriver: navigator.webdriver, + ownNavigatorProperties: Object.getOwnPropertyNames(navigator), + webdriverOnPrototype: 'webdriver' in Navigator.prototype, })); expect(observed.userAgent).toBe(profile.fingerprint?.userAgent); expect(observed.platform).toBe('Win32'); expect(observed.timezone).toBe('Asia/Tokyo'); - expect(observed.webdriver).toBeFalsy(); + expect(observed.webdriver).toBe(false); + // Detectors flag an own property where real Chrome has none. + expect(observed.ownNavigatorProperties).toEqual([]); + expect(observed.webdriverOnPrototype).toBe(true); } finally { browser.disconnect(); } @@ -109,4 +156,96 @@ e2e('a browser launched for a profile', () => { await site.close(); } }, 60_000); + + it('gives Intl, navigator.languages and Accept-Language the profile language', async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-profiles-e2e-')); + launched = await launchChrome({ profile, userDataDir, headless: true }); + + const site = await serveWorkerPage(); + const browser = await puppeteer.connect({ browserWSEndpoint: launched.wsEndpoint }); + try { + const page = await browser.newPage(); + await page.goto(site.url); + const seen = await page.evaluate(() => ({ + locale: Intl.DateTimeFormat().resolvedOptions().locale, + languages: navigator.languages.join(','), + })); + expect(seen).toEqual({ locale: 'ja-JP', languages: 'ja-JP,ja' }); + // What Chrome sends for ja-JP,ja: the header must not disagree with navigator.languages. + expect(site.acceptLanguage()).toBe('ja-JP,ja;q=0.9'); + } finally { + browser.disconnect(); + await site.close(); + } + }, 60_000); + + // Needs a fingerprint-chromium build: KERNEL_PATH=/path/to/Chromium. + it.skipIf(!process.env.KERNEL_PATH)('gives Intl the profile locale in kernel mode', async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-profiles-e2e-')); + launched = await launchChrome({ profile, userDataDir, headless: true, chromePath: process.env.KERNEL_PATH }); + expect(launched.engine).toBe('kernel'); + + const browser = await puppeteer.connect({ browserWSEndpoint: launched.wsEndpoint }); + try { + const page = await browser.newPage(); + await page.goto('data:text/html,e2e'); + const locale = await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().locale); + expect(locale).toBe('ja-JP'); + } finally { + browser.disconnect(); + } + }, 60_000); + + it('spoofs classic, module, shared and service workers like the page', async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browser-profiles-e2e-')); + launched = await launchChrome({ profile, userDataDir, headless: true }); + + const site = await serveWorkerPage(); + const browser = await puppeteer.connect({ browserWSEndpoint: launched.wsEndpoint }); + try { + const page = await browser.newPage(); + await page.goto(site.url); + const seen = await page.evaluate(async () => { + const fromWorker = (worker: Worker) => + new Promise((resolve) => { + worker.onmessage = (event) => resolve(event.data); + worker.onerror = (event) => resolve(`error: ${event.message}`); + setTimeout(() => resolve('timeout'), 5000); + }); + const shared = new SharedWorker('/probe.js'); + return { + page: navigator.userAgent, + classic: await fromWorker(new Worker('/probe.js')), + module: await fromWorker(new Worker('/probe.js', { type: 'module' })), + shared: await new Promise((resolve) => { + shared.port.onmessage = (event) => resolve(event.data); + shared.onerror = () => resolve('error'); + setTimeout(() => resolve('timeout'), 5000); + }), + service: await new Promise((resolve) => { + navigator.serviceWorker.onmessage = (event) => resolve(event.data); + navigator.serviceWorker.register('/probe.js').catch((error) => resolve(`error: ${error}`)); + setTimeout(() => resolve('timeout'), 5000); + }), + }; + }); + + const expected = { + platform: 'Win32', + cores: 4, + language: 'ja-JP', + locale: 'ja-JP', + userAgent: seen.page, + appVersion: seen.page.replace('Mozilla/', ''), + }; + expect(seen.page).toBe(profile.fingerprint?.userAgent); + expect(seen.classic).toEqual(expected); + expect(seen.module).toEqual(expected); + expect(seen.shared).toEqual(expected); + expect(seen.service).toEqual(expected); + } finally { + browser.disconnect(); + await site.close(); + } + }, 60_000); }); diff --git a/src/launcher/launch.ts b/src/launcher/launch.ts index 2585cdc..cf2721d 100644 --- a/src/launcher/launch.ts +++ b/src/launcher/launch.ts @@ -2,12 +2,13 @@ // @aitofy/browser-profiles - Launch Chrome for a profile // ============================================================================ -import type { LaunchOptions, LaunchResult, StoredProfile } from '../types'; +import type { LaunchOptions, LaunchResult, ResolvedEngine, StoredProfile } from '../types'; import { createLogger } from '../log'; import { deleteLockFile, lockFilePath, probeDevToolsPort, writeLockFile } from '../storage'; import { startAutoAttach } from './auto-attach'; import { buildChromeFlags, clearStaleSingletonLocks } from './chrome-flags'; import { getChromePath } from './chrome-path'; +import { binaryLooksLikeKernel, buildKernelFlags, resolveEngine } from './kernel'; import { claimProfile } from './claim'; import { loadCdp, loadChromeLauncher } from './deps'; import type { CdpClient, CdpConnectOptions, LaunchedChrome } from './deps'; @@ -162,6 +163,13 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): } const executablePath = getChromePath(chromePath); + const engine = resolveEngine(options.engine ?? 'auto', binaryLooksLikeKernel(executablePath)); + if (engine === 'kernel' && profile.fingerprint?.userAgent) { + log.warn( + 'fingerprint.userAgent is ignored in kernel mode. The binary sets the user agent from its ' + + 'own version; a pinned string would disagree with it.' + ); + } const relayUrl = profile.proxy ? await startProxyRelay(profile.proxy) : undefined; const timezone = await resolveTimezone(profile); @@ -182,6 +190,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): args: flagArgs, extensions, proxyServer: relayUrl, + kernelFlags: engine === 'kernel' ? buildKernelFlags(profile, timezone) : undefined, }), ...(startingUrl ? { startingUrl } : {}), userDataDir, @@ -194,7 +203,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): // chrome-launcher already spawns with detached:true on POSIX; unref frees our event loop. chrome.process?.unref(); } - log.info(`Chrome started on port ${chrome.port} (pid ${chrome.pid})`); + log.info(`Chrome started on port ${chrome.port} (pid ${chrome.pid}, ${engine})`); } catch (error) { log.error('Chrome launch failed', error); await closeProxyRelay(relayUrl); @@ -211,8 +220,8 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): try { releaseSession = detached - ? await protectFirstTabOnly(chrome.port, profile, timezone) - : await protectEveryTab(wsEndpoint, profile, timezone, userDataDir, relayUrl); + ? await protectFirstTabOnly(chrome.port, profile, timezone, engine) + : await protectEveryTab(wsEndpoint, profile, timezone, engine, userDataDir, relayUrl); } catch (error) { await abortLaunch(chrome, relayUrl); throw error; @@ -226,6 +235,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): startedAt: Date.now(), proxyUrl: relayUrl, detached, + engine, }); const close = async (): Promise => { @@ -240,7 +250,15 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): } }; - return { wsEndpoint, pid: chrome.pid, port: chrome.port, profileId: profile.id, close, detached }; + return { + wsEndpoint, + pid: chrome.pid, + port: chrome.port, + profileId: profile.id, + close, + detached, + engine, + }; } /** Ask the browser which Chrome it really is, so the UA cannot claim another version. */ @@ -257,7 +275,14 @@ async function readChromeVersion(client: CdpClient): Promise { } /** The plan needs the running Chrome's version, so it is built once the client is connected. */ -async function planFor(client: CdpClient, profile: StoredProfile, timezone: string): Promise { +async function planFor( + client: CdpClient, + profile: StoredProfile, + timezone: string, + engine: ResolvedEngine +): Promise { + if (engine === 'kernel') return buildProtectionPlan(profile, timezone, FALLBACK_CHROME_VERSION, 'kernel'); + const plan = buildProtectionPlan(profile, timezone, await readChromeVersion(client)); const mismatch = plan.userAgentMismatch; if (mismatch) { @@ -273,10 +298,15 @@ async function planFor(client: CdpClient, profile: StoredProfile, timezone: stri * Detached: the CDP socket would pin our event loop, so it is closed again. * Cost: only the tab open right now carries the per-session overrides. */ -async function protectFirstTabOnly(port: number, profile: StoredProfile, timezone: string): Promise { +async function protectFirstTabOnly( + port: number, + profile: StoredProfile, + timezone: string, + engine: ResolvedEngine +): Promise { const client = await connectCdp({ port }); try { - const plan = await planFor(client, profile, timezone); + const plan = await planFor(client, profile, timezone, engine); const session = sessionOf(client); await applyProtections(session, plan); await applyCookies(session, plan); @@ -291,11 +321,12 @@ async function protectEveryTab( wsEndpoint: string, profile: StoredProfile, timezone: string, + engine: ResolvedEngine, userDataDir: string, relayUrl: string | undefined ): Promise<() => Promise> { const client = await connectCdp({ target: wsEndpoint }); - const plan = await planFor(client, profile, timezone); + const plan = await planFor(client, profile, timezone, engine); await applyCookies(sessionOf(client), plan); const handle = await startAutoAttach({ diff --git a/src/launcher/protections.test.ts b/src/launcher/protections.test.ts index 7ba7cef..12c59a2 100644 --- a/src/launcher/protections.test.ts +++ b/src/launcher/protections.test.ts @@ -68,7 +68,7 @@ describe('buildProtectionPlan', () => { expect(plan.userAgentOverride).toMatchObject({ userAgent: 'UA/1.0', platform: 'MacIntel', - acceptLanguage: 'fr-FR', + acceptLanguage: 'fr-FR,fr', }); const metadata = plan.userAgentOverride.userAgentMetadata as { platform: string }; expect(metadata.platform).toBe('macOS'); @@ -112,8 +112,21 @@ describe('buildProtectionPlan', () => { }); }); +describe('kernel protection plan', () => { + it('installs no script and overrides only the locale', async () => { + const plan = buildProtectionPlan(profile, 'Europe/Paris', { major: 152, full: '152.0.0.0' }, 'kernel'); + expect(plan.engine).toBe('kernel'); + expect(plan.initScript).toBe(''); + expect(plan.userAgentMismatch).toBeNull(); + + const session = fakeSession(); + await applyProtections(session, plan); + expect(session.calls).toEqual([{ method: 'Emulation.setLocaleOverride', params: { locale: 'fr-FR' } }]); + }); +}); + describe('applyProtections', () => { - it('sends user agent, script and timezone in order', async () => { + it('sends user agent, script, timezone and locale in order', async () => { const session = fakeSession(); const plan = buildProtectionPlan(profile); @@ -125,10 +138,12 @@ describe('applyProtections', () => { 'Page.enable', 'Page.addScriptToEvaluateOnNewDocument', 'Emulation.setTimezoneOverride', + 'Emulation.setLocaleOverride', ]); expect(session.calls[1].params).toEqual(plan.userAgentOverride); expect(session.calls[3].params).toEqual({ source: plan.initScript }); expect(session.calls[4].params).toEqual({ timezoneId: 'Europe/Paris' }); + expect(session.calls[5].params).toEqual({ locale: 'fr-FR' }); }); it('propagates a failure of a protection that matters', async () => { diff --git a/src/launcher/protections.ts b/src/launcher/protections.ts index 87d4714..e69318e 100644 --- a/src/launcher/protections.ts +++ b/src/launcher/protections.ts @@ -2,8 +2,8 @@ // @aitofy/browser-profiles - Anti-detect injections, applied per CDP session // ============================================================================ -import type { ProfileCookie, StoredProfile } from '../types'; -import { FINGERPRINT_DEFAULTS, getProfileProtectionScripts } from '../fingerprint'; +import type { ProfileCookie, ResolvedEngine, StoredProfile } from '../types'; +import { FINGERPRINT_DEFAULTS, getProfileProtectionScripts, getProfileWorkerScript } from '../fingerprint'; import { buildUserAgentMetadata, resolveUserAgent, FALLBACK_CHROME_VERSION } from '../user-agent'; import type { ChromeVersion } from '../user-agent'; import type { CdpClient, CdpParams, CdpResult } from './deps'; @@ -26,10 +26,16 @@ export function sessionOf(client: CdpClient, sessionId?: string): CdpSession { export interface ProtectionPlan { userAgentOverride: CdpParams; initScript: string; + /** Navigator/WebGL spoof evaluated in worker targets before they run. */ + workerScript: string; timezoneId: string; + /** BCP 47 locale for Intl. Chrome on macOS ignores --lang and uses the system locale. */ + locale: string; cookies: CdpParams[]; /** Set when the profile pins a UA claiming another Chrome major than the browser running it. */ userAgentMismatch: { claimed: number; running: number } | null; + /** kernel: the binary owns the fingerprint; only the locale override is sent. */ + engine: ResolvedEngine; } function cookieParams(cookie: ProfileCookie): CdpParams { @@ -46,13 +52,20 @@ function cookieParams(cookie: ProfileCookie): CdpParams { }; } +/** Matches navigator.languages ([language, primary]); Chrome adds the q-values to the header itself. */ +function acceptLanguageHeader(language: string): string { + const primary = language.split('-')[0]; + return primary === language ? language : `${language},${primary}`; +} + /** Pure: turn a profile into the exact CDP payloads its tabs need. */ /** `timezoneId` is the timezone Chrome was started with (TZ env), so Intl and the clock agree. */ /** `chromeVersion` is the version the browser really reports, so the UA cannot claim another one. */ export function buildProtectionPlan( profile: StoredProfile, timezoneId: string = profile.timezone || FALLBACK_TIMEZONE, - chromeVersion: ChromeVersion = FALLBACK_CHROME_VERSION + chromeVersion: ChromeVersion = FALLBACK_CHROME_VERSION, + engine: ResolvedEngine = 'inject' ): ProtectionPlan { const platform = profile.fingerprint?.platform || FINGERPRINT_DEFAULTS.platform; const language = profile.fingerprint?.language || FINGERPRINT_DEFAULTS.language; @@ -66,13 +79,18 @@ export function buildProtectionPlan( userAgentOverride: { userAgent, platform, - acceptLanguage: language, + acceptLanguage: acceptLanguageHeader(language), userAgentMetadata: buildUserAgentMetadata(platform, version), }, - initScript: getProfileProtectionScripts(profile.fingerprint), + initScript: engine === 'kernel' ? '' : getProfileProtectionScripts(profile.fingerprint), + workerScript: engine === 'kernel' ? '' : getProfileWorkerScript(profile.fingerprint, userAgent), timezoneId, + locale: language, cookies: (profile.cookies ?? []).map(cookieParams), - userAgentMismatch: mismatch ? { claimed: version.major, running: chromeVersion.major } : null, + userAgentMismatch: engine === 'kernel' + ? null + : (mismatch ? { claimed: version.major, running: chromeVersion.major } : null), + engine, }; } @@ -81,12 +99,31 @@ export function buildProtectionPlan( * Both the tab Chrome starts with and every auto-attached tab go through here. */ export async function applyProtections(session: CdpSession, plan: ProtectionPlan): Promise { + // Hooks on top of a kernel build are the signal detectors look for. The locale is + // the exception: Chrome on macOS ignores --lang, and no kernel switch sets Intl. + if (plan.engine === 'kernel') { + await session.send('Emulation.setLocaleOverride', { locale: plan.locale }); + return; + } + await session.send('Network.enable'); await session.send('Network.setUserAgentOverride', plan.userAgentOverride); // New-document scripts only run for a session whose Page agent is enabled. await session.send('Page.enable'); await session.send('Page.addScriptToEvaluateOnNewDocument', { source: plan.initScript }); await session.send('Emulation.setTimezoneOverride', { timezoneId: plan.timezoneId }); + await session.send('Emulation.setLocaleOverride', { locale: plan.locale }); +} + +/** + * Apply a plan to a worker paused at start. Dedicated workers inherit the + * page's overrides; shared and service workers have their own, set here. + */ +export async function applyWorkerProtections(session: CdpSession, plan: ProtectionPlan): Promise { + if (plan.engine === 'kernel') return; + + await session.send('Network.setUserAgentOverride', plan.userAgentOverride); + await session.send('Runtime.evaluate', { expression: plan.workerScript }); } /** diff --git a/src/launcher/reuse.ts b/src/launcher/reuse.ts index 934f2f8..55fe0bb 100644 --- a/src/launcher/reuse.ts +++ b/src/launcher/reuse.ts @@ -60,5 +60,6 @@ export async function reuseExisting( close, reused: true, detached: lock.detached, + engine: lock.engine === 'kernel' ? 'kernel' : 'inject', }; } diff --git a/src/launcher/standalone.ts b/src/launcher/standalone.ts index a93b333..6adbefa 100644 --- a/src/launcher/standalone.ts +++ b/src/launcher/standalone.ts @@ -68,6 +68,11 @@ export interface StandaloneLaunchOptions { * @default false */ detached?: boolean; + + /** + * Where the fingerprint is applied. Default auto. + */ + engine?: 'auto' | 'kernel' | 'inject'; } /** @@ -93,6 +98,9 @@ export interface StandaloneLaunchResult { * Close function */ close: () => Promise; + + /** kernel spoofs inside Chromium. inject patches from JavaScript. */ + engine: 'kernel' | 'inject'; } /** @@ -139,6 +147,7 @@ export async function launchChromeStandalone(options: StandaloneLaunchOptions = args = [], extensions = [], detached = false, + engine, } = options; const tempProfile: StoredProfile = { @@ -171,6 +180,7 @@ export async function launchChromeStandalone(options: StandaloneLaunchOptions = args, extensions, detached, + engine, }); const close = async () => { @@ -193,6 +203,7 @@ export async function launchChromeStandalone(options: StandaloneLaunchOptions = pid: result.pid, port: result.port, close, + engine: result.engine, }; } diff --git a/src/profile-manager.ts b/src/profile-manager.ts index a3a7bac..96bf5c9 100644 --- a/src/profile-manager.ts +++ b/src/profile-manager.ts @@ -381,6 +381,7 @@ export class BrowserProfiles { slowMo: options?.slowMo, timeout: options?.timeout, detached: options?.detached, + engine: options?.engine, }); log.debug(`Launched profile ${profileId} on port ${result.port}`); diff --git a/src/storage.ts b/src/storage.ts index c8be746..9ac4d33 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -73,6 +73,8 @@ export interface BrowserLockInfo { detached?: boolean; /** Placeholder written before Chrome starts: nothing is running behind it yet. */ claiming?: boolean; + /** Engine the browser was launched with. Absent on locks written before kernel mode. */ + engine?: 'kernel' | 'inject'; } export function lockFilePath(userDataDir: string): string { @@ -92,6 +94,7 @@ export function readLockFile(userDataDir: string): BrowserLockInfo | null { proxyUrl: parsed.proxyUrl, detached: parsed.detached, claiming: parsed.claiming, + engine: parsed.engine === 'kernel' || parsed.engine === 'inject' ? parsed.engine : undefined, }; } catch { return null; diff --git a/src/types.ts b/src/types.ts index 9c35c24..e43524c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,6 +148,16 @@ export interface FingerprintConfig { audio?: 'noise' | 'real'; } +/** + * auto: kernel when the executable is fingerprint-chromium, otherwise JavaScript injection. + * kernel: engine-level spoof. Refuses to launch on stock Chrome. + * inject: JavaScript and CDP overrides, even if the binary is a kernel build. + */ +export type FingerprintEngine = 'auto' | 'kernel' | 'inject'; + +/** The engine a running browser actually launched with. */ +export type ResolvedEngine = 'kernel' | 'inject'; + /** * Browser profile configuration */ @@ -208,6 +218,11 @@ export interface LaunchOptions { timeout?: number; /** Let Chrome outlive this Node process (no SIGINT handler, child unref'd) */ detached?: boolean; + /** + * Where the fingerprint is applied. + * Default `auto`: kernel when the executable has the fingerprint-chromium switch. + */ + engine?: FingerprintEngine; } /** @@ -242,6 +257,8 @@ export interface LaunchResult { reused?: boolean; /** True when Chrome outlives this process, so only the first tab is protected */ detached?: boolean; + /** kernel spoofs inside Chromium. inject patches from JavaScript. */ + engine: ResolvedEngine; } /** From b159c29336aa6973aa75088c36d0e3e5d39319c8 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 12:30:55 +0700 Subject: [PATCH 2/6] docs: kernel engine, leak fixes and a measured benchmark scripts/measure.mjs opens browserscan, creepjs, pixelscan, fingerprint.com, deviceandbrowserinfo, rebrowser and a Cloudflare challenge in a tab no automation client is attached to, masks IP addresses, and saves text and screenshots. It drives stock Chrome, both engines, AdsPower, GPM Login, Multilogin or any open browser. docs/benchmark.md holds the results; the README and llms.txt quote it instead of the earlier scores, which could not be reproduced. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + CHANGELOG.md | 28 +++++ README.md | 9 +- docs/anti-detect.md | 95 +++++++++++------ docs/benchmark.md | 80 ++++++++++++++ llms.txt | 30 +++--- scripts/measure.mjs | 249 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 49 deletions(-) create mode 100644 docs/benchmark.md create mode 100644 scripts/measure.mjs diff --git a/.gitignore b/.gitignore index d92da86..f645deb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db # Claude Code worktrees .claude/ + +# Benchmark captures (screenshots can show the host locale) +measurements/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aefb872..d91f99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ 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). +## [Unreleased] + +### Added + +- **Kernel engine.** `engine: 'auto' | 'kernel' | 'inject'` on launch, `--engine` in the CLI and + MCP. With a [fingerprint-chromium](https://github.com/adryfish/fingerprint-chromium) binary the + fingerprint is spoofed inside Chromium from a per-profile seed and no JavaScript hooks are + installed. `auto` picks it when the binary has the switch. + +### Fixed + +- **`Intl` reported the host locale.** Chrome on macOS ignores `--lang`, so `Intl.DateTimeFormat()` + showed e.g. `zh-CN` while `navigator.language` said `en-US`. Every tab and worker now gets + `Emulation.setLocaleOverride` from `fingerprint.language`. +- **Module, shared and service workers leaked the real machine** (platform, cores, GPU, and for + shared workers the user agent). Every worker target is now paused at start, spoofed and resumed. +- **Own properties on `navigator` gave the inject engine away.** `webdriver`, `plugins`, + `connection` and `getBattery` were defined on the `navigator` object, where real Chrome has none; + they now replace the getters on `Navigator.prototype`. browserscan no longer reports a bot. +- **Accept-Language disagreed with `navigator.languages`.** The header was `en-US` while + `navigator.languages` said `en-US,en`; it is now `en-US,en;q=0.9`, as Chrome sends. + +### Changed + +- Detector scores in the README now come from `scripts/measure.mjs`, run against AdsPower, GPM + Login and Multilogin on the same machine and proxy: see `docs/benchmark.md`. The earlier numbers + could not be reproduced. + ## [0.3.0] - 2026-09-12 ### Added diff --git a/README.md b/README.md index ccd8c76..35c96e5 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,7 @@ Like **n8n** for automation or **Affine** for notes, this is **AdsPower for deve | Vendor lock-in | **Open source** (MIT) | | No customization | **Full control** | -| Site | Score | -|------|-------| -| browserleaks.com | 100% | -| pixelscan.net | 100% | -| browserscan.net | 95% | -| creepjs | 85% | - -95% is the Puppeteer ceiling; 100% needs a modified Chromium. What is protected, how it is injected, and the honest limits: [docs/anti-detect.md](./docs/anti-detect.md). +Kernel mode drives fingerprint-chromium, which spoofs inside Chromium and installs no JavaScript hooks. It scores 100% on browserscan and passes a Cloudflare challenge, next to AdsPower, GPM Login and Multilogin on the same machine and proxy; none of them, paid or not, hides from fingerprint.com. Every number and how to reproduce it: [docs/benchmark.md](./docs/benchmark.md). What is protected, how each engine applies it, and the honest limits: [docs/anti-detect.md](./docs/anti-detect.md). ## Quick start diff --git a/docs/anti-detect.md b/docs/anti-detect.md index 3b6625d..1d484d3 100644 --- a/docs/anti-detect.md +++ b/docs/anti-detect.md @@ -1,9 +1,46 @@ # Anti-detect What a profile actually hides, how it is applied, and where the limits are. No extension is -installed and no Chromium is patched: everything is applied over CDP when the browser launches. +installed. Two engines: -## What is protected +| Engine | When | How | +|--------|------|-----| +| `kernel` | The executable is a [fingerprint-chromium](https://github.com/adryfish/fingerprint-chromium) build, or `engine: "kernel"` | Chromium spoofs the fingerprint from a per-profile seed (`--fingerprint`). No JavaScript hooks are installed. | +| `inject` | Stock Google Chrome, or `engine: "inject"` | Scripts and CDP overrides, applied when the browser launches. | + +`engine` defaults to `auto`: kernel when the binary's switch table (the framework on macOS, +`chrome.dll` on Windows) contains `fingerprint-platform`, inject otherwise. With no `chromePath` and no `CHROMIUM_PATH`/`CHROME_PATH`, a kernel build at +`~/.aitofy/browser-profiles/kernel/` or a `Chromium.app` that has the switch is preferred over +Google Chrome. + +## Kernel engine + +The seed is a hash of the profile id, so the same profile is the same machine and a duplicate is +a new one. Flags passed to the binary: + +| Surface | Flag | +|---------|------| +| Seed | `--fingerprint=` | +| Platform | `--fingerprint-platform` from `Win32` / `MacIntel` / `Linux x86_64` | +| Brand | `--fingerprint-brand=Chrome`. The brand version is the binary's own, so the UA matches the browser. `fingerprint.userAgent` is ignored and a warning is logged. | +| CPU cores | `--fingerprint-hardware-concurrency` when the profile sets it. Otherwise the seed decides. | +| Language | `--lang` and `--accept-lang` | +| Timezone | `--timezone` plus the `TZ` environment variable | +| WebRTC | `--disable-non-proxied-udp` | +| Canvas, audio | On, unless the profile sets that surface to `real` (`--disable-spoofing`) | +| WebGL, fonts, client rects, device memory, webdriver | Derived inside the binary from the seed. `fingerprint.webgl` is not applied; current kernels no longer accept a vendor string. | + +Cookies are still installed once with `Storage.setCookies`. The launcher keeps one CDP connection +that pauses each new page only to send `Emulation.setLocaleOverride`: Chrome on macOS ignores +`--lang`, so without it `Intl` reports the host's locale. No script and no other override is sent. +Puppeteer and Playwright integrations do not add an init script in this mode. + +`engine: "kernel"` on stock Chrome fails before launch. Skipping the hooks without the binary +would open a browser that spoofs nothing. + +## Inject engine + +What the JavaScript path protects: | Surface | What happens | |---------|--------------| @@ -12,10 +49,10 @@ installed and no Chromium is patched: everything is applied over CDP when the br | WebRTC | Local and public IP leaks blocked, including behind a proxy. | | Canvas | A small random channel shift, fixed for the lifetime of the page, added to canvas readbacks (`getImageData`, `toDataURL`, `toBlob`). | | WebGL | Vendor and renderer strings and GPU parameters spoofed, noise added to buffer reads. The pair is picked once when the profile is created, consistent with its platform, and stored in `fingerprint.webgl`, so the same GPU is reported on every page and every launch. | -| Workers | `Worker` and `SharedWorker` are wrapped so the navigator and WebGL spoof runs before the worker script: a worker reports the same values as the main thread. | +| Workers | Every worker target (dedicated, module, shared, service) is paused at start, gets the navigator, user agent and WebGL spoof, and is then resumed, so it reports the same values as the main thread. `Worker` and `SharedWorker` are also wrapped from the page, for clients that inject without the launcher. | | Audio | Tiny noise added to AudioContext output, inaudible but enough to break the fingerprint. | | Timezone | Chrome is started with `TZ` set and `Emulation.setTimezoneOverride` is applied, so the clock and `Intl` agree. With a proxy and no explicit timezone, it is detected from the proxy exit IP. | -| Language | `navigator.language` and the Accept-Language header come from the same profile field. | +| Language | `navigator.language`, the Accept-Language header and `Intl` (`Emulation.setLocaleOverride`) come from the same profile field. Chrome on macOS ignores `--lang`, so without the override `Intl` reports the host's locale. | | Cookies | Cookies stored on the profile are installed once per launch with `Storage.setCookies`, so every tab and every browser context sees them. | Defaults when a profile does not say: a Windows user agent for the running Chrome's version, @@ -28,52 +65,52 @@ compare the two. ## Per-tab injection -The protections are not applied once to the first tab. The process that launched Chrome keeps a CDP +Inject mode only. The protections are not applied once to the first tab. The process that launched +Chrome keeps a CDP connection with auto-attach enabled: every new page or iframe target is paused before its first -script runs, gets the user agent override, the init script and the timezone override, and is only -then resumed. +script runs, gets the user agent override, the init script, the timezone and the locale override, +and is only then resumed. Worker targets get the worker spoof the same way. That is why tabs opened later — by your automation, by Playwright MCP, by a `window.open`, or by a click — are protected exactly like the first one. The exception is `detached`, which has no owning process and therefore injects into the first tab -only. See [browser-lifecycle.md](./browser-lifecycle.md). +only. Kernel mode keeps the spoof on later tabs, because it is a process flag; only the locale +override is limited to the first tab. See +[browser-lifecycle.md](./browser-lifecycle.md). ## Score -| Site | Score | Note | -|------|-------|------| -| browserleaks.com | 100% | All checks passed | -| pixelscan.net | 100% | Hardware fingerprint passed | -| browserscan.net | 95% | Bot Control -5% (Puppeteer limitation) | -| creepjs | 85% | Minor deductions | - -95% is the best achievable with Puppeteer or Playwright. The remaining 5% is the Puppeteer ceiling: -AdsPower and Multilogin reach 100% by shipping a modified Chromium binary, while this library drives -standard Chrome with JS injection over CDP. For social media, e-commerce and scraping work, 95% is -sufficient. +Measured against AdsPower, GPM Login and Multilogin in [benchmark.md](./benchmark.md). The inject +engine hooks Canvas and WebGL from JavaScript, and creepjs flags those hooks whatever values they +return; kernel mode does not install them. ## Honest limits -- Detection is an arms race. The scores above were measured against those sites at a point in time, - not a guarantee about any particular site today. +- Detection is an arms race. A score is a measurement against one site at one point in time, not a + guarantee about any particular site today. - `headless: true` is more detectable than a real window, whatever is injected. The default is false for that reason. - A fingerprint that contradicts itself defeats the point: a `MacIntel` platform with a Windows user agent, or a `Europe/Berlin` timezone behind a US proxy, is more suspicious than no spoofing at all. Match `timezone`, `language` and `platform` to the proxy exit country. -- `detached: true` drops to flag-level protection only, and cannot use an authenticated proxy. +- `detached: true` on the inject engine drops to flag-level protection only, and cannot use an + authenticated proxy. Kernel mode keeps the spoof, because it lives in the process flags, but + later tabs report the host's `Intl` locale on macOS. The + proxy limit is the same: the relay dies with the opener. - Nothing here hides behaviour. Request rate, mouse movement and login patterns are not part of a fingerprint, and they are what most account bans are based on. - Chrome's own crash reporting and update channels are unchanged; this library adds zero telemetry of its own. -- With an external CDP client attached (Playwright MCP, `puppeteer.connect`), a popup opened by - `window.open` may escape injection: the other client can resume the popup before our session - injects into it. Tabs created through `newPage` are protected. Through the library's own - Puppeteer and Playwright integrations the gap is closed, because they re-inject the same bundle - with `evaluateOnNewDocument` / `addInitScript`. -- Module workers and service workers are passed through untouched; only `Worker` and `SharedWorker` - are wrapped. +- Inject mode, with an external CDP client attached (Playwright MCP, `puppeteer.connect`): a popup + opened by `window.open` may escape injection, because the other client can resume the popup + before our session injects into it. Tabs created through `newPage` are protected. Through the + library's own Puppeteer and Playwright integrations the gap is closed, because they re-inject the + same bundle with `evaluateOnNewDocument` / `addInitScript`. Kernel mode does not inject, so this + race does not apply. +- Worker targets are only spoofed while the launching process holds its CDP connection. With + `detached: true`, module and service workers keep the real values; only `Worker` and + `SharedWorker` are wrapped from the page. ## See also diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 0000000..a76dd9c --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,80 @@ +# Benchmark + +Every engine of this library next to the anti-detect browsers people pay for, on the same machine, +the same proxy and the same pages. Measured 2026-10-04 with [`scripts/measure.mjs`](../scripts/measure.mjs). + +## Setup + +- Host: macOS on Apple Silicon. Every profile claims Windows, so a mismatch with the real machine is + something each engine has to hide. +- Proxy: one US residential proxy for all engines (Multilogin excepted, see the notes). +- Profile: `Win32`, `en-US`, timezone from the proxy. +- Method: the site is opened in a new tab while no automation client is attached. After 30–40 s the + script reconnects only to read the page and take a screenshot. IP addresses are masked before capture. + +| Engine | Browser | +|--------|---------| +| `kernel` (this library) | fingerprint-chromium 148 | +| `inject` (this library) | Google Chrome 154 | +| AdsPower | SunBrowser 153 | +| GPM Login | Chromium 149 | +| Multilogin | Mimic 153 | +| Stock Chrome | Google Chrome 154 driven by Puppeteer, no spoofing (the control) | + +## Results + +| Engine | browserscan | Cloudflare challenge | fingerprint.com bot | fingerprint.com tampering | fingerprint.com suspect score | pixelscan | deviceandbrowserinfo | rebrowser | +|--------|-------------|----------------------|---------------------|---------------------------|-------------------------------|-----------|----------------------|-----------| +| `kernel` | 100% | passed | not detected | detected | 38 | inconsistent, masking | human | clean | +| AdsPower | 100% | passed | not detected | detected | 30 | consistent | human | clean | +| GPM Login | 100% | passed | not detected | detected | 16 | did not finish | human | clean | +| Multilogin | 90% | passed | not detected | detected | 39 | inconsistent | human | clean | +| `inject` | 85% | blocked | bot | detected | 45 | did not finish | human | clean | +| Stock Chrome | 75% | blocked | bot | not detected | 18 | inconsistent | bot | `navigator.webdriver` | + +creepjs: `kernel`, AdsPower, GPM Login and Multilogin all show 0% stealth and no extension; `inject` +shows 20% stealth and its hooks are reported as an extension. + +Sites: [browserscan](https://www.browserscan.net/), +[Cloudflare challenge](https://www.scrapingcourse.com/antibot-challenge), +[fingerprint.com playground](https://demo.fingerprint.com/playground), +[pixelscan](https://pixelscan.net/fingerprint-check), +[deviceandbrowserinfo](https://deviceandbrowserinfo.com/are_you_a_bot), +[rebrowser bot detector](https://bot-detector.rebrowser.net/), +[creepjs](https://abrahamjuliot.github.io/creepjs/). + +## What it shows + +- No engine hides from fingerprint.com. Every anti-detect browser here, paid or not, is reported as + `anti_detect_browser: true`. +- `kernel` matches the paid browsers on Cloudflare, bot detection, deviceandbrowserinfo and rebrowser. + It loses to AdsPower on pixelscan. +- `inject` is the fallback for when no kernel binary is installed. JavaScript hooks are visible to + creepjs and fingerprint.com, and Cloudflare blocks it. + +## Limits of this run + +- One run, one machine, one proxy. Scores move between runs and between site versions. +- `inject` was measured again after its navigator overrides moved to `Navigator.prototype`: that + removed the browserscan bot flag and the rebrowser `navigator.webdriver` finding. Its pixelscan and + deviceandbrowserinfo cells are from the run before. +- Multilogin ran on its free plan, which has no automation API. Its own profile and proxy were used, + opened by hand with remote debugging allowed. Its 90% on browserscan is a timezone that did not + match that proxy, a profile setting rather than the engine. +- GPM Login kept the host's language for the whole profile. That is consistent, so browserscan did + not deduct for it. +- pixelscan did not finish its fingerprint step for GPM Login and `inject` within the wait. +- The fingerprint-chromium build is six Chrome majors behind stable; its user agent says so. +- iphey reported every engine, stock Chrome included, as unreliable, so it is left out. +- Gologin was not measured: no active plan was available. + +## Reproduce + +```bash +npm run build +export PROXY_URL=http://user:pass@host:port +KERNEL_PATH=/path/to/fingerprint-chromium node scripts/measure.mjs +``` + +The header of the script lists the variables that add AdsPower, GPM Login, Multilogin or any +browser that is already open. Screenshots and page text land in `measurements/`. diff --git a/llms.txt b/llms.txt index 0af708e..f93e320 100644 --- a/llms.txt +++ b/llms.txt @@ -14,6 +14,7 @@ This file is the whole story in one page. The same material, split up, lives nex docs/playwright-mcp.md Hand a wsEndpoint to Playwright MCP or any CDP client. docs/configuration.md Storage path precedence, Chrome path, logging, on-disk layout. docs/anti-detect.md What is protected, per-tab injection, scores, honest limits. + docs/benchmark.md Measured scores next to AdsPower, GPM Login and Multilogin, and how to rerun them. docs/browser-lifecycle.md Who owns the browser, reuse, cross-process close, detached trade-offs. docs/troubleshooting.md Common failures with their exact message and the fix. docs/adr/0001-command-registry.md Why one registry generates the CLI, the MCP server and the library API. @@ -23,16 +24,20 @@ This file is the whole story in one page. The same material, split up, lives nex ## What it does Stores browser profiles locally (proxy, timezone, fingerprint, cookies, Chrome user data) and -launches real Chrome with anti-detect injections applied over CDP: WebRTC, Canvas, WebGL, Audio, -navigator, workers, timezone. The user agent and Client Hints are built from the major version of -the Chrome that is actually running, read with Browser.getVersion at launch; a pinned -fingerprint.userAgent wins and logs a warning when its major differs. The WebGL vendor/renderer is -picked once per profile, consistent with its platform, and stored in fingerprint.webgl. Worker and -SharedWorker run the same spoof as the main thread. Authenticated proxies are handled by a local -relay. Profiles persist between runs, so logins survive. - -Anti-detect scores: browserleaks.com 100%, pixelscan.net 100%, browserscan.net 95%, creepjs 85%. -95% is the ceiling for Puppeteer/Playwright; 100% needs a modified Chromium. +launches real Chrome with anti-detect. engine=auto (the default) uses kernel mode when the +executable is fingerprint-chromium (engine-level spoof from a per-profile seed, no JavaScript +hooks) and JavaScript injection over CDP otherwise. Inject mode covers +WebRTC, Canvas, WebGL, Audio, navigator, workers, timezone, Intl locale. The user agent and Client Hints are +built from the major version of the Chrome that is actually running, read with Browser.getVersion +at launch; a pinned fingerprint.userAgent wins and logs a warning when its major differs. Kernel +mode ignores a pinned user agent so the UA stays the binary's own version. The WebGL +vendor/renderer is picked once per profile, consistent with its platform, and stored in +fingerprint.webgl (inject mode; the kernel derives the GPU from the seed). Every worker target +(dedicated, module, shared, service) runs the same spoof as the main thread. Authenticated proxies are handled by a local relay. +Profiles persist between runs, so logins survive. + +Benchmark (docs/benchmark.md, scripts/measure.mjs): kernel mode scores 100% on browserscan and passes +a Cloudflare challenge, like AdsPower; inject mode scores 85% and creepjs flags its hooks. ## Installation @@ -182,8 +187,9 @@ injections live there. exits. In a script, background it with a stdin that stays open, or read the endpoint from browser_status instead. - detached opts out: CLI --detached, MCP detached: true, library profiles.launch(id, {detached: true}). - Chrome outlives the caller but with flag-level protection only, no authenticated-proxy relay, - injections on the first tab only. Use for one-shot scripts: browser-profiles open acme --detached --json. + Chrome outlives the caller. Inject mode keeps flag-level protection only, no authenticated-proxy + relay, injections on the first tab only. Kernel mode keeps the spoof (it is a process flag); the + relay limit is the same. Use for one-shot scripts: browser-profiles open acme --detached --json. - browser_close / browser close works from any process by reading the profile lock file. ## Library API diff --git a/scripts/measure.mjs b/scripts/measure.mjs new file mode 100644 index 0000000..f2146e0 --- /dev/null +++ b/scripts/measure.mjs @@ -0,0 +1,249 @@ +// Detector scores for the README, measured instead of claimed. +// +// npm run build +// set -a; source ~/.aff/proxy.env; set +a # PROXY_URL=http://user:pass@host:port +// KERNEL_PATH=/path/to/fingerprint-chromium PROXY_LABEL=residential node scripts/measure.mjs +// +// Engines: stock (plain Chrome behind the proxy, the control), inject, kernel when +// KERNEL_PATH is set, gpm (GPM Login) when GPM_PROFILE_ID names a running GPM's profile, +// adspower when ADSPOWER_PROFILE_ID is set (ADSPOWER_API, ADSPOWER_API_KEY), multilogin +// when MLX_TOKEN is set (a running Multilogin X agent), and any browser already open when +// CDP_WS is its browser WebSocket (named by CDP_LABEL). +// ENGINES=inject,kernel and SITES=browserscan pick subsets; SETTLE_MS sets the wait per site. +// A profile engine opens each site while no automation client is attached, then is +// reconnected only to mask IP addresses and capture the result. +// Output: measurements//-.{png,txt} and results.json. + +import fs from 'fs'; +import path from 'path'; +import puppeteer from 'puppeteer-core'; +import { getChromePath, launchChromeStandalone, parseProxyUrl } from '../dist/index.mjs'; + +const SITES = [ + { name: 'browserscan', url: 'https://www.browserscan.net/' }, + { name: 'creepjs', url: 'https://abrahamjuliot.github.io/creepjs/' }, + { name: 'pixelscan', url: 'https://pixelscan.net/fingerprint-check' }, + { name: 'tls', url: 'https://tls.peet.ws/api/clean' }, + { name: 'fingerprint', url: 'https://demo.fingerprint.com/playground' }, + { name: 'iphey', url: 'https://iphey.com/' }, + { name: 'rebrowser', url: 'https://bot-detector.rebrowser.net/' }, + { name: 'deviceinfo', url: 'https://deviceandbrowserinfo.com/are_you_a_bot' }, + { name: 'cloudflare', url: 'https://www.scrapingcourse.com/antibot-challenge' }, +]; + +const SETTLE_MS = Number(process.env.SETTLE_MS) || 30_000; +const IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; + +const FINGERPRINT = { platform: 'Win32', language: 'en-US', hardwareConcurrency: 8, deviceMemory: 8 }; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function requireProxy() { + if (!process.env.PROXY_URL) throw new Error('PROXY_URL is not set. Source the proxy env file first.'); + const parsed = parseProxyUrl(process.env.PROXY_URL); + if (!parsed.ok) throw new Error(parsed.error.message); + return parsed.data; +} + +/** Runs in the page: hides every IPv4 address so screenshots can be published. */ +function maskAddresses() { + const ipv4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + node.textContent = node.textContent.replace(ipv4, 'x.x.x.x'); + } + for (const input of document.querySelectorAll('input, textarea')) { + input.value = input.value.replace(ipv4, 'x.x.x.x'); + } +} + +/** Stock Chrome stays driven by Puppeteer: it is the only way to answer proxy auth there. */ +async function measureStock(proxy, site) { + const browser = await puppeteer.launch({ + executablePath: getChromePath(), + headless: false, + defaultViewport: null, + args: [`--proxy-server=${proxy.type}://${proxy.host}:${proxy.port}`], + }); + try { + const page = await browser.newPage(); + if (proxy.username) await page.authenticate({ username: proxy.username, password: proxy.password ?? '' }); + await page.goto(site.url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await delay(SETTLE_MS); + await capture(page, 'stock', site); + } finally { + await browser.close(); + } +} + +async function measureProfile(proxy, engine, site) { + const launched = await launchChromeStandalone({ + proxy, + engine, + chromePath: engine === 'kernel' ? process.env.KERNEL_PATH : undefined, + fingerprint: FINGERPRINT, + }); + try { + if (launched.engine !== engine) throw new Error(`Asked for ${engine}, launched ${launched.engine}.`); + await measureDetached(launched.wsEndpoint, engine, site); + } finally { + await launched.close(); + } +} + +/** GPM Login, for comparison: its local API starts a profile whose proxy is set inside GPM. */ +async function measureGpm(site) { + const api = 'http://127.0.0.1:9495/api/v1/profiles'; + const id = process.env.GPM_PROFILE_ID; + let started = await (await fetch(`${api}/start/${id}`)).json(); + // Stop returns before the previous browser has exited. + for (let attempt = 0; !started.success && started.message === 'ProfileInUse' && attempt < 15; attempt++) { + await delay(2000); + started = await (await fetch(`${api}/start/${id}`)).json(); + } + if (!started.success) throw new Error(`GPM start failed: ${started.message}`); + try { + // GPM v5 leaves websocket_debugging_url empty; the port is enough to find it. + const wsEndpoint = await debuggerUrl(started.data.remote_debugging_port); + await measureDetached(wsEndpoint, 'gpm', site); + } finally { + await fetch(`${api}/stop/${id}`); + } +} + +/** AdsPower, for comparison. Its local API needs a paid plan; the key is sent as a bearer token. */ +async function measureAdsPower(site) { + const api = process.env.ADSPOWER_API || 'http://127.0.0.1:50325'; + const headers = { authorization: `Bearer ${process.env.ADSPOWER_API_KEY}` }; + const id = process.env.ADSPOWER_PROFILE_ID; + const call = async (route) => { + // The local API allows about one request per second. + await delay(1100); + return (await fetch(`${api}/api/v1/browser/${route}?user_id=${id}`, { headers })).json(); + }; + const started = await call('start'); + if (started.code !== 0) throw new Error(`AdsPower start failed: ${started.msg}`); + try { + await measureDetached(started.data.ws.puppeteer, 'adspower', site); + } finally { + await call('stop'); + await delay(3000); + } +} + +/** Multilogin X, for comparison: a one-off quick profile, nothing is saved to the workspace. */ +async function measureMultilogin(proxy, site) { + const launcher = 'https://launcher.mlx.yt:45001/api'; + const headers = { 'content-type': 'application/json', authorization: `Bearer ${process.env.MLX_TOKEN}` }; + const flags = Object.fromEntries( + ['navigator', 'audio', 'localization', 'geolocation', 'timezone', 'graphics', 'webrtc', 'fonts', + 'media_devices', 'screen', 'ports'].map((name) => [`${name}_masking`, 'mask']) + ); + const body = { + browser_type: 'mimic', + os_type: 'windows', + automation: 'puppeteer', + parameters: { + flags: { ...flags, geolocation_popup: 'prompt', canvas_noise: 'natural', graphics_noise: 'natural', + proxy_masking: 'custom', startup_behavior: 'custom' }, + proxy: { host: proxy.host, type: proxy.type, port: proxy.port, username: proxy.username, + password: proxy.password }, + fingerprint: {}, + }, + }; + const started = await (await fetch(`${launcher}/v3/profile/quick`, { + method: 'POST', headers, body: JSON.stringify(body), + })).json(); + const port = started.data?.port; + if (!port) throw new Error(`Multilogin start failed: ${JSON.stringify(started.status ?? started)}`); + try { + await measureDetached(await debuggerUrl(port), 'multilogin', site); + } finally { + await fetch(`${launcher}/v1/profile/stop/p/${started.data.id}`, { headers }); + } +} + +/** The browser answers on its debugging port a moment after GPM reports it started. */ +async function debuggerUrl(port) { + for (let attempt = 0; attempt < 30; attempt++) { + try { + const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + return version.webSocketDebuggerUrl; + } catch { + await delay(500); + } + } + throw new Error(`Nothing answered on debugging port ${port}.`); +} + +async function measureDetached(wsEndpoint, engine, site) { + const opener = await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); + const session = await opener.target().createCDPSession(); + await session.send('Target.createTarget', { url: site.url }); + opener.disconnect(); + + await delay(SETTLE_MS); + + const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: null }); + try { + const host = new URL(site.url).host; + const page = (await browser.pages()).find((candidate) => candidate.url().includes(host)); + if (!page) throw new Error(`No tab on ${host}.`); + await capture(page, engine, site); + } finally { + browser.disconnect(); + } +} + +let outDir = ''; + +async function capture(page, engine, site) { + await page.evaluate(maskAddresses); + const text = await page.evaluate(() => document.body.innerText); + const base = path.join(outDir, `${engine}-${site.name}`); + fs.writeFileSync(`${base}.txt`, text.replace(IPV4, 'x.x.x.x')); + await page.screenshot({ path: `${base}.png`, fullPage: true }); +} + +async function main() { + const proxy = requireProxy(); + const available = [ + 'stock', + 'inject', + ...(process.env.KERNEL_PATH ? ['kernel'] : []), + ...(process.env.GPM_PROFILE_ID ? ['gpm'] : []), + ...(process.env.ADSPOWER_PROFILE_ID ? ['adspower'] : []), + ...(process.env.MLX_TOKEN ? ['multilogin'] : []), + ...(process.env.CDP_WS ? [process.env.CDP_LABEL || 'cdp'] : []), + ]; + const picked = process.env.ENGINES?.split(','); + const engines = picked ? available.filter((engine) => picked.includes(engine)) : available; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + outDir = path.join('measurements', [stamp, process.env.PROXY_LABEL].filter(Boolean).join('-')); + fs.mkdirSync(outDir, { recursive: true }); + + const results = []; + for (const engine of engines) { + const pickedSites = process.env.SITES?.split(','); + const sites = pickedSites ? SITES.filter((site) => pickedSites.includes(site.name)) : SITES; + for (const site of sites) { + try { + if (engine === 'stock') await measureStock(proxy, site); + else if (engine === 'gpm') await measureGpm(site); + else if (engine === 'adspower') await measureAdsPower(site); + else if (engine === 'multilogin') await measureMultilogin(proxy, site); + // A browser someone already opened: its own proxy applies, not PROXY_URL. + else if (engine === (process.env.CDP_LABEL || 'cdp')) await measureDetached(process.env.CDP_WS, engine, site); + else await measureProfile(proxy, engine, site); + results.push({ engine, site: site.name, ok: true }); + } catch (error) { + results.push({ engine, site: site.name, ok: false, error: String(error).replace(IPV4, 'x.x.x.x') }); + } + console.log(results.at(-1)); + } + } + fs.writeFileSync(path.join(outDir, 'results.json'), JSON.stringify(results, null, 2)); + console.log(`Wrote ${outDir}`); +} + +await main(); From 60cbc6f0ece62957bdc6fd80cb33c58da8d8ec5d Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 13:36:26 +0700 Subject: [PATCH 3/6] chore: keep competitor research out of the repo Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index f645deb..fe4a32b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ Thumbs.db # Benchmark captures (screenshots can show the host locale) measurements/ + +# Competitor research stays local, out of the public repo +docs/reverse-engineering.md +scripts/research/ From eeadfb883335739607d465a49fe02094192c5952 Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 13:36:35 +0700 Subject: [PATCH 4/6] fix(launcher): take the timezone from the proxy exit IP The timezone was looked up for proxy.host. A gateway proxy exits from another address, often another country, so the profile could report a timezone that disagrees with the IP sites see. The lookup now goes through the relay, falling back to the host when that fails. Co-Authored-By: Claude Opus 5.5 --- src/launcher/launch.ts | 10 +++--- src/launcher/proxy.test.ts | 51 +++++++++++++++++++++++++++ src/launcher/proxy.ts | 71 ++++++++++++++++++++++++++++---------- 3 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 src/launcher/proxy.test.ts diff --git a/src/launcher/launch.ts b/src/launcher/launch.ts index cf2721d..ca199b6 100644 --- a/src/launcher/launch.ts +++ b/src/launcher/launch.ts @@ -17,6 +17,7 @@ import type { ProtectionPlan } from './protections'; import { assertDetachedLaunchAllowed, closeProxyRelay, + detectExitLocation, detectTimezoneFromIP, startProxyRelay, } from './proxy'; @@ -41,12 +42,13 @@ export interface ChromeLaunchOptions extends LaunchOptions { const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -/** Timezone for Chrome's TZ, from the profile, else the proxy exit node, else this host. */ -async function resolveTimezone(profile: StoredProfile): Promise { +/** Timezone for Chrome's TZ, from the profile, else the proxy exit IP, else this host. */ +async function resolveTimezone(profile: StoredProfile, relayUrl: string | undefined): Promise { if (profile.timezone) return profile.timezone; if (profile.proxy) { - const geo = await detectTimezoneFromIP(profile.proxy.host); + const exit = relayUrl ? await detectExitLocation(relayUrl) : null; + const geo = exit ?? await detectTimezoneFromIP(profile.proxy.host); if (geo) { log.info(`Auto-detected timezone: ${geo.timezone} (${geo.city}, ${geo.country})`); return geo.timezone; @@ -171,7 +173,7 @@ async function launchClaimed(options: ChromeLaunchOptions, userDataDir: string): ); } const relayUrl = profile.proxy ? await startProxyRelay(profile.proxy) : undefined; - const timezone = await resolveTimezone(profile); + const timezone = await resolveTimezone(profile, relayUrl); log.debug(`Launching Chrome: ${executablePath}`); log.debug(`User data dir: ${userDataDir}`); diff --git a/src/launcher/proxy.test.ts b/src/launcher/proxy.test.ts new file mode 100644 index 0000000..a5d732b --- /dev/null +++ b/src/launcher/proxy.test.ts @@ -0,0 +1,51 @@ +import http from 'http'; +import { afterEach, describe, expect, it } from 'vitest'; +import { detectExitLocation } from './proxy'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) await new Promise((resolve) => server?.close(() => resolve())); + server = null; +}); + +/** A forward proxy stand-in: records what it was asked for and answers like ip-api.com. */ +async function fakeRelay(body: string): Promise<{ url: string; requested: () => string | undefined }> { + let requested: string | undefined; + server = http.createServer((request, response) => { + requested = request.url; + response.end(body); + }); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + return { url: `http://127.0.0.1:${port}`, requested: () => requested }; +} + +describe('detectExitLocation', () => { + it('asks through the relay for the caller IP, not for the proxy host', async () => { + const relay = await fakeRelay(JSON.stringify({ + status: 'success', country: 'United States', regionName: 'New Jersey', city: 'Verona', + timezone: 'America/New_York', + })); + + const geo = await detectExitLocation(relay.url); + + expect(relay.requested()).toMatch(/^http:\/\/ip-api\.com\/json\/\?fields=/); + expect(geo).toEqual({ + timezone: 'America/New_York', country: 'United States', city: 'Verona', region: 'New Jersey', + }); + }); + + it('returns null when the lookup fails or answers garbage', async () => { + const failed = await fakeRelay(JSON.stringify({ status: 'fail' })); + expect(await detectExitLocation(failed.url)).toBeNull(); + await new Promise((resolve) => server?.close(() => resolve())); + + const garbage = await fakeRelay('blocked'); + expect(await detectExitLocation(garbage.url)).toBeNull(); + }); + + it('returns null when the relay is unreachable', async () => { + expect(await detectExitLocation('http://127.0.0.1:9', 2000)).toBeNull(); + }); +}); diff --git a/src/launcher/proxy.ts b/src/launcher/proxy.ts index 1f137e2..b35689b 100644 --- a/src/launcher/proxy.ts +++ b/src/launcher/proxy.ts @@ -2,6 +2,7 @@ // @aitofy/browser-profiles - Proxy relay and IP-based timezone detection // ============================================================================ +import http from 'http'; import type { ProxyConfig } from '../types'; import { createLogger } from '../log'; import { loadProxyChain } from './deps'; @@ -54,29 +55,63 @@ export interface GeoLocation { export async function detectTimezoneFromIP(ip: string): Promise { try { // ip-api.com: free, no API key, 45 requests/minute. - const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,country,regionName,city,timezone`); - const data = await response.json() as { - status: string; - country: string; - regionName: string; - city: string; - timezone: string; - }; - - if (data.status === 'success') { - return { - timezone: data.timezone, - country: data.country, - city: data.city, - region: data.regionName, - }; - } - return null; + const response = await fetch(`http://ip-api.com/json/${ip}?fields=${GEO_FIELDS}`); + return parseGeo(await response.text()); } catch { return null; } } +const GEO_FIELDS = 'status,country,regionName,city,timezone'; + +function parseGeo(body: string): GeoLocation | null { + const data = JSON.parse(body) as { + status?: string; + country?: string; + regionName?: string; + city?: string; + timezone?: string; + }; + if (data.status !== 'success' || !data.timezone) return null; + return { + timezone: data.timezone, + country: data.country ?? '', + city: data.city ?? '', + region: data.regionName ?? '', + }; +} + +/** + * Locate the IP a site actually sees by asking through the relay. A gateway proxy's + * host is not its exit IP, so looking the host up can return another country. + * @returns Geo info, or null when the lookup fails. + */ +export function detectExitLocation(relayUrl: string, timeoutMs = 10_000): Promise { + const relay = new URL(relayUrl); + return new Promise((resolve) => { + const request = http.get({ + host: relay.hostname, + port: relay.port, + path: `http://ip-api.com/json/?fields=${GEO_FIELDS}`, + headers: { host: 'ip-api.com' }, + timeout: timeoutMs, + }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { body += chunk; }); + response.on('end', () => { + try { + resolve(parseGeo(body)); + } catch { + resolve(null); + } + }); + }); + request.on('timeout', () => request.destroy()); + request.on('error', () => resolve(null)); + }); +} + /** Timezone of the proxy exit node, or the fallback when it cannot be resolved. */ export async function autoDetectTimezone(proxy: ProxyConfig): Promise { const geo = await detectTimezoneFromIP(proxy.host); From ee7a63420c9d543865d59323a9d70c1421d064fb Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 13:36:46 +0700 Subject: [PATCH 5/6] feat(kernel): claim the host OS when a profile names none On Apple Silicon a Windows claim reads as a virtual machine to fingerprint.com: the CPU shows through. Measured on the same proxy, claiming macOS clears the virtual machine and rare device flags and drops the suspect score from 38 to 8, while browserscan stays at 100% and the Cloudflare challenge still passes (2 of 3 runs). A profile that sets fingerprint.platform is unchanged; the inject engine still defaults to Windows, so the standalone launcher no longer forces Win32 itself. When another OS is claimed on a Mac, the display is forced to sRGB, since only Mac displays report a P3 gamut and HDR. The benchmark script can now leave the platform unset (PROFILE_PLATFORM=host); docs and the changelog cover this and the exit-IP timezone fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 89 ++++++++++++++++++++++++++++++++++--- docs/anti-detect.md | 5 ++- docs/benchmark.md | 9 +++- scripts/measure.mjs | 9 +++- src/launcher/kernel.test.ts | 24 +++++++++- src/launcher/kernel.ts | 25 +++++++++-- src/launcher/standalone.ts | 3 +- 7 files changed, 148 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91f99c..2ce8537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fingerprint is spoofed inside Chromium from a per-profile seed and no JavaScript hooks are installed. `auto` picks it when the binary has the switch. +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. +- Detector scores in the README now come from `scripts/measure.mjs`, run against AdsPower, GPM + Login and Multilogin on the same machine and proxy: see `docs/benchmark.md`. The earlier numbers + could not be reproduced. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **`Intl` reported the host locale.** Chrome on macOS ignores `--lang`, so `Intl.DateTimeFormat()` showed e.g. `zh-CN` while `navigator.language` said `en-US`. Every tab and worker now gets `Emulation.setLocaleOverride` from `fingerprint.language`. @@ -27,12 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Accept-Language disagreed with `navigator.languages`.** The header was `en-US` while `navigator.languages` said `en-US,en`; it is now `en-US,en;q=0.9`, as Chrome sends. -### Changed - -- Detector scores in the README now come from `scripts/measure.mjs`, run against AdsPower, GPM - Login and Multilogin on the same machine and proxy: see `docs/benchmark.md`. The earlier numbers - could not be reproduced. - ## [0.3.0] - 2026-09-12 ### Added @@ -80,8 +87,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `fingerprint.webgl` instead of being random per page load. - Removed the hardcoded `USER_AGENTS` list; the User-Agent is built from the running Chrome. +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **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 reads the real version via `Browser.getVersion` and builds the UA @@ -164,8 +181,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.9] - 2026-01-12 +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **Chrome stale lock file cleanup** 🔓 - Auto-cleans `SingletonLock`, `SingletonCookie`, `SingletonSocket` files before launching - These files are left behind when Chrome crashes and prevent new instances from starting @@ -181,8 +208,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.8] - 2026-01-12 +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **Connection retry on stale browser** ⚡ - If puppeteer.connect() fails with ECONNREFUSED, automatically retries with a fresh browser launch - Handles race condition where browser crashes between detection and connection @@ -251,8 +288,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 // Connects to existing browser instead of failing! ``` +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - Running multiple scripts with the same profile ID no longer causes "port already in use" errors - Stale lock files are automatically cleaned up when browser process has died @@ -285,8 +332,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.4] - 2026-01-12 +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **Multiple pages issue** - Fixed browser opening with 2 pages instead of 1 - `withPuppeteer()`, `quickLaunch()`, and `createSession()` now reuse existing browser pages - Prevents duplicate empty pages from appearing on browser launch @@ -418,8 +475,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 }); ``` +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. + - **ESM Compatibility** - Package now works correctly in ESM environments (tsx, vite, next.js) - Replaced `require()` with dynamic `import()` in `getPuppeteer()` - Properly handles both ESM default exports and CJS module.exports @@ -439,7 +506,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.1] - 2026-01-07 +### Changed + +- **Kernel profiles without a platform claim this machine's OS.** Windows claimed on Apple Silicon + is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. + A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to + Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. + ### Fixed + +- **The timezone came from the proxy's host, not its exit IP.** A gateway proxy exits elsewhere, so + the profile could report another country's timezone. It is now looked up through the proxy. - Minor bug fixes and stability improvements ## [0.1.0] - 2026-01-06 diff --git a/docs/anti-detect.md b/docs/anti-detect.md index 1d484d3..f83ea66 100644 --- a/docs/anti-detect.md +++ b/docs/anti-detect.md @@ -21,11 +21,12 @@ a new one. Flags passed to the binary: | Surface | Flag | |---------|------| | Seed | `--fingerprint=` | -| Platform | `--fingerprint-platform` from `Win32` / `MacIntel` / `Linux x86_64` | +| Platform | `--fingerprint-platform` from `Win32` / `MacIntel` / `Linux x86_64`. A profile with no platform claims this machine's OS: the CPU shows through, and Windows claimed on Apple Silicon reads as a virtual machine to fingerprint.com. | +| Display | `--force-color-profile=srgb` when another OS is claimed on a Mac, since only Mac displays report a P3 gamut and HDR. | | Brand | `--fingerprint-brand=Chrome`. The brand version is the binary's own, so the UA matches the browser. `fingerprint.userAgent` is ignored and a warning is logged. | | CPU cores | `--fingerprint-hardware-concurrency` when the profile sets it. Otherwise the seed decides. | | Language | `--lang` and `--accept-lang` | -| Timezone | `--timezone` plus the `TZ` environment variable | +| Timezone | `--timezone` plus the `TZ` environment variable. With a proxy and no `timezone`, it comes from the IP the proxy exits from, looked up through the proxy itself. | | WebRTC | `--disable-non-proxied-udp` | | Canvas, audio | On, unless the profile sets that surface to `real` (`--disable-spoofing`) | | WebGL, fonts, client rects, device memory, webdriver | Derived inside the binary from the seed. `fingerprint.webgl` is not applied; current kernels no longer accept a vendor string. | diff --git a/docs/benchmark.md b/docs/benchmark.md index a76dd9c..981a358 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -25,7 +25,8 @@ the same proxy and the same pages. Measured 2026-10-04 with [`scripts/measure.mj | Engine | browserscan | Cloudflare challenge | fingerprint.com bot | fingerprint.com tampering | fingerprint.com suspect score | pixelscan | deviceandbrowserinfo | rebrowser | |--------|-------------|----------------------|---------------------|---------------------------|-------------------------------|-----------|----------------------|-----------| -| `kernel` | 100% | passed | not detected | detected | 38 | inconsistent, masking | human | clean | +| `kernel`, macOS claimed (default on a Mac) | 100% | passed | not detected | detected | 8 | inconsistent, masking | human | not run | +| `kernel`, Windows claimed | 100% | passed | not detected | detected | 38 | inconsistent, masking | human | clean | | AdsPower | 100% | passed | not detected | detected | 30 | consistent | human | clean | | GPM Login | 100% | passed | not detected | detected | 16 | did not finish | human | clean | | Multilogin | 90% | passed | not detected | detected | 39 | inconsistent | human | clean | @@ -54,7 +55,11 @@ Sites: [browserscan](https://www.browserscan.net/), ## Limits of this run -- One run, one machine, one proxy. Scores move between runs and between site versions. +- One run, one machine, one proxy. Scores move between runs and between site versions. The + macOS-claimed `kernel` row was run three times for browserscan and Cloudflare: one run scored 95% + ("Anti-detect browser") and was held at the Cloudflare check, the other two are the row above. +- Claiming Windows on Apple Silicon is what fingerprint.com reports as a virtual machine and a rare + device: with macOS claimed, both flags clear and the suspect score drops from 38 to 8. - `inject` was measured again after its navigator overrides moved to `Navigator.prototype`: that removed the browserscan bot flag and the rebrowser `navigator.webdriver` finding. Its pixelscan and deviceandbrowserinfo cells are from the run before. diff --git a/scripts/measure.mjs b/scripts/measure.mjs index f2146e0..514cc25 100644 --- a/scripts/measure.mjs +++ b/scripts/measure.mjs @@ -34,7 +34,14 @@ const SITES = [ const SETTLE_MS = Number(process.env.SETTLE_MS) || 30_000; const IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g; -const FINGERPRINT = { platform: 'Win32', language: 'en-US', hardwareConcurrency: 8, deviceMemory: 8 }; +// PROFILE_PLATFORM=host leaves the platform unset, so the kernel claims this machine's OS. +const PLATFORM = process.env.PROFILE_PLATFORM || 'Win32'; +const FINGERPRINT = { + ...(PLATFORM === 'host' ? {} : { platform: PLATFORM }), + language: 'en-US', + hardwareConcurrency: 8, + deviceMemory: 8, +}; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/launcher/kernel.test.ts b/src/launcher/kernel.test.ts index eceb5df..ea580d7 100644 --- a/src/launcher/kernel.test.ts +++ b/src/launcher/kernel.test.ts @@ -10,6 +10,7 @@ import { KERNEL_SWITCH, binaryLooksLikeKernel, buildKernelFlags, + hostKernelPlatform, kernelPlatform, kernelSeed, resolveEngine, @@ -56,6 +57,12 @@ describe('kernelPlatform', () => { expect(kernelPlatform('MacIntel')).toBe('macos'); expect(kernelPlatform('Linux x86_64')).toBe('linux'); }); + + it('maps the host OS', () => { + expect(hostKernelPlatform('darwin')).toBe('macos'); + expect(hostKernelPlatform('win32')).toBe('windows'); + expect(hostKernelPlatform('linux')).toBe('linux'); + }); }); describe('buildKernelFlags', () => { @@ -75,7 +82,8 @@ describe('buildKernelFlags', () => { it('omits cores and disable-spoofing when the profile leaves them unset', () => { const flags = buildKernelFlags( { id: 'bare', name: 'Bare', createdAt: 0, updatedAt: 0 }, - 'America/New_York' + 'America/New_York', + 'windows' ); expect(flags.some((flag) => flag.startsWith('--fingerprint-hardware-concurrency'))).toBe(false); expect(flags.some((flag) => flag.startsWith('--disable-spoofing'))).toBe(false); @@ -83,6 +91,20 @@ describe('buildKernelFlags', () => { expect(flags).toContain('--accept-lang=en-US,en'); }); + it('claims the host OS when the profile names no platform', () => { + const bare = { id: 'bare', name: 'Bare', createdAt: 0, updatedAt: 0 }; + expect(buildKernelFlags(bare, 'UTC', 'macos')).toContain('--fingerprint-platform=macos'); + expect(buildKernelFlags(bare, 'UTC', 'linux')).toContain('--fingerprint-platform=linux'); + }); + + it('hides the Mac display gamut only when another OS is claimed on a Mac', () => { + const windows = { id: 'w', name: 'W', createdAt: 0, updatedAt: 0, fingerprint: { platform: 'Win32' } }; + const bare = { id: 'b', name: 'B', createdAt: 0, updatedAt: 0 }; + expect(buildKernelFlags(windows, 'UTC', 'macos')).toContain('--force-color-profile=srgb'); + expect(buildKernelFlags(bare, 'UTC', 'macos')).not.toContain('--force-color-profile=srgb'); + expect(buildKernelFlags(windows, 'UTC', 'windows')).not.toContain('--force-color-profile=srgb'); + }); + it('is placed before a caller flag, so the caller can override it', () => { const flags = buildChromeFlags({ profile, diff --git a/src/launcher/kernel.ts b/src/launcher/kernel.ts index 6073b3c..676abec 100644 --- a/src/launcher/kernel.ts +++ b/src/launcher/kernel.ts @@ -133,13 +133,25 @@ export function kernelSeed(profileId: string): number { return positive === 0 ? 1 : positive; } -export function kernelPlatform(navigatorPlatform: string): 'windows' | 'macos' | 'linux' { +type KernelPlatform = 'windows' | 'macos' | 'linux'; + +export function kernelPlatform(navigatorPlatform: string): KernelPlatform { const value = navigatorPlatform.toLowerCase(); if (value.includes('mac')) return 'macos'; if (value.includes('linux') || value === 'x11') return 'linux'; return 'windows'; } +/** + * The OS this machine really is. A profile that names no platform claims it: the CPU + * shows through (an ARM NaN under a Windows claim reads as a virtual machine). + */ +export function hostKernelPlatform(nodePlatform: NodeJS.Platform = process.platform): KernelPlatform { + if (nodePlatform === 'darwin') return 'macos'; + if (nodePlatform === 'win32') return 'windows'; + return 'linux'; +} + function acceptLanguage(language: string): string { const primary = language.split('-')[0]; if (!primary || primary.toLowerCase() === language.toLowerCase()) return language; @@ -158,10 +170,14 @@ function disabledSurfaces(fingerprint: FingerprintConfig | undefined): string[] * matches the binary; a pinned fingerprint.userAgent is not applied. * GPU strings are derived from the seed (Chrome 144 removed the vendor flags). */ -export function buildKernelFlags(profile: StoredProfile, timezone: string): string[] { +export function buildKernelFlags( + profile: StoredProfile, + timezone: string, + host: KernelPlatform = hostKernelPlatform() +): string[] { const fingerprint = profile.fingerprint; const language = fingerprint?.language || FINGERPRINT_DEFAULTS.language; - const platform = kernelPlatform(fingerprint?.platform || FINGERPRINT_DEFAULTS.platform); + const platform = fingerprint?.platform ? kernelPlatform(fingerprint.platform) : host; const flags = [ `--fingerprint=${kernelSeed(profile.id)}`, `--fingerprint-platform=${platform}`, @@ -179,5 +195,8 @@ export function buildKernelFlags(profile: StoredProfile, timezone: string): stri const off = disabledSurfaces(fingerprint); if (off.length > 0) flags.push(`--disable-spoofing=${off.join(',')}`); + // Only Mac displays report a P3 gamut and HDR; another OS claimed on a Mac must not. + if (host === 'macos' && platform !== 'macos') flags.push('--force-color-profile=srgb'); + return flags; } diff --git a/src/launcher/standalone.ts b/src/launcher/standalone.ts index 6adbefa..160b0f0 100644 --- a/src/launcher/standalone.ts +++ b/src/launcher/standalone.ts @@ -160,7 +160,8 @@ export async function launchChromeStandalone(options: StandaloneLaunchOptions = fingerprint: { userAgent: fingerprint.userAgent, language: fingerprint.language || 'en-US', - platform: fingerprint.platform || 'Win32', + // Unset lets each engine pick: inject claims Windows, kernel claims this machine. + platform: fingerprint.platform, hardwareConcurrency: fingerprint.hardwareConcurrency || 8, deviceMemory: fingerprint.deviceMemory || 8, }, From 95011eb1b105f73a140609dc2e44d48c0713deac Mon Sep 17 00:00:00 2001 From: namvippro Date: Sun, 4 Oct 2026 15:34:41 +0700 Subject: [PATCH 6/6] docs: pin the tested kernel build and state kernel limits Names the fingerprint-chromium build the benchmark ran on with its SHA-256 (matching the release digest), how to point the library at any other binary, and what kernel mode does not cover: Windows claimed on Apple Silicon, and Widevine. Fixes the outdated detection note in configuration.md. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ docs/anti-detect.md | 20 ++++++++++++++++++++ docs/configuration.md | 3 ++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce8537..bab8f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is reported as a virtual machine by fingerprint.com (suspect score 38); macOS claimed scores 8. A profile that sets `fingerprint.platform` is unchanged, and the inject engine still defaults to Windows. When another OS is claimed on a Mac, `--force-color-profile=srgb` hides the P3/HDR display. +- `docs/anti-detect.md` names the kernel build that was tested, with its SHA-256, and states what + kernel mode does not cover: Windows claimed on Apple Silicon, and Widevine. - Detector scores in the README now come from `scripts/measure.mjs`, run against AdsPower, GPM Login and Multilogin on the same machine and proxy: see `docs/benchmark.md`. The earlier numbers could not be reproduced. diff --git a/docs/anti-detect.md b/docs/anti-detect.md index f83ea66..fef76fa 100644 --- a/docs/anti-detect.md +++ b/docs/anti-detect.md @@ -31,6 +31,19 @@ a new one. Flags passed to the binary: | Canvas, audio | On, unless the profile sets that surface to `real` (`--disable-spoofing`) | | WebGL, fonts, client rects, device memory, webdriver | Derived inside the binary from the seed. `fingerprint.webgl` is not applied; current kernels no longer accept a vendor string. | +### Which binary + +The library drives whatever binary it is given and does not ship one. Any build can be chosen with +`chromePath`, `CHROMIUM_PATH` or `CHROME_PATH` ([configuration.md](./configuration.md#chrome-executable)). +The build the benchmark ran on: + +| Build | Platform | SHA-256 | +|-------|----------|---------| +| [fingerprint-chromium 148.0.7778.215-1.1](https://github.com/adryfish/fingerprint-chromium/releases/tag/148.0.7778.215) | macOS arm64 (`…_macos.dmg`) | `b72f091e2e1a7583eed389c4b8e3534ed355e568af8c8bbf8fc30a25e23ca679` | + +The binary is a third party's: its fingerprint quality, update pace and security fixes are not +something this library can promise. It trails Chrome stable, which ships a major every two weeks. + Cookies are still installed once with `Storage.setCookies`. The launcher keeps one CDP connection that pauses each new page only to send `Emulation.setLocaleOverride`: Chrome on macOS ignores `--lang`, so without it `Intl` reports the host's locale. No script and no other override is sent. @@ -109,6 +122,13 @@ return; kernel mode does not install them. library's own Puppeteer and Playwright integrations the gap is closed, because they re-inject the same bundle with `evaluateOnNewDocument` / `addInitScript`. Kernel mode does not inject, so this race does not apply. +- Kernel mode claiming Windows on an Apple Silicon Mac is not well supported. The CPU shows + through (fingerprint.com reports a virtual machine), and pixelscan reports masking because the + binary lacks the full set of Windows core fonts. Claim the host's OS, which is the default, or run + Windows profiles on a Windows machine. +- fingerprint-chromium builds ship without Widevine, which Google Chrome always has, so a page that + asks for `com.widevine.alpha` can tell the kernel from Chrome, and DRM video does not play. The + library does not add it: Widevine is licensed by Google and is not ours to copy or distribute. - Worker targets are only spoofed while the launching process holds its CDP connection. With `detached: true`, module and service workers keep the real values; only `Worker` and `SharedWorker` are wrapped from the page. diff --git a/docs/configuration.md b/docs/configuration.md index 0ca7b3a..8195db2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,7 +32,8 @@ Precedence, highest first: 1. `chromePath` — `new BrowserProfiles({ chromePath })` or `LaunchOptions.chromePath`. Used only if the file exists. 2. `CHROMIUM_PATH` or `CHROME_PATH` in the environment, again only if the file exists. -3. A fingerprint-chromium kernel, if the file exists and contains `--fingerprint-platform`: +3. A fingerprint-chromium kernel, if the file exists and its switch table (the framework on macOS, + `chrome.dll` on Windows) contains `fingerprint-platform`: `~/.aitofy/browser-profiles/kernel/` (`Chromium.app` on macOS, `chrome` on Linux, `chrome.exe` on Windows), then `Chromium.app` in `/Applications` or `~/Applications`. 4. The platform's usual install locations, Google Chrome first.