Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-09-12

### Fixed

- **User-Agent now matches the running Chrome.** The UA and Client Hints (`Sec-CH-UA`, `navigator.userAgentData`) were pinned to Chrome 119-121; current Chrome is 152, and detectors compare the two. The launcher now reads the real version via CDP and builds the UA from it. An explicit `fingerprint.userAgent` still wins and logs a warning when its major version differs.
- **WebGL vendor/renderer no longer leaks the real GPU** on the Puppeteer and Playwright paths. Protection scripts are injected through the automation library's own API, and the launcher attaches at the browser target so pages opened later are covered too.
- **Web workers now see the spoofed navigator and WebGL** (#1). `Worker` and `SharedWorker` are wrapped so the spoof runs before the worker script. Module workers and service workers are passed through untouched.

### Changed

- WebGL vendor/renderer is chosen once per profile, consistent with its platform, and persisted in `fingerprint.webgl` instead of being random per page load.
- Removed the hardcoded `USER_AGENTS` list. New pure helpers exported: `buildUserAgent`, `buildBrands`, `buildUserAgentMetadata`, `parseChromeVersion`, `resolveUserAgent`.

### Added

- Unit tests (vitest) for UA/fingerprint consistency and WebGL/worker scripts, plus a headless integration test that is skipped when no Chrome is installed.
- GitHub Actions CI on Ubuntu and macOS.

## [0.2.12] - 2026-01-14

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aitofy/browser-profiles",
"version": "0.2.12",
"version": "0.3.0",
"description": "Self-hosted anti-detect browser profiles. Open-source AdsPower alternative for Puppeteer & Playwright.",
"keywords": [
"antidetect",
Expand Down Expand Up @@ -122,4 +122,4 @@
"url": "https://github.com/aitofy-dev/browser-profiles/issues"
},
"sideEffects": false
}
}
232 changes: 136 additions & 96 deletions src/chrome-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ import path from 'path';
import os from 'os';
import type { StoredProfile, LaunchResult, LaunchOptions, ProxyConfig } from './types';
import { getAllProtectionScripts } from './fingerprint';
import {
buildUserAgentMetadata,
parseChromeVersion,
resolveUserAgent,
FALLBACK_CHROME_MAJOR,
type ChromeVersion,
} from './user-agent';

/**
* Ask the connected browser which Chrome it really is (e.g. "HeadlessChrome/152.0.7977.83").
*/
async function getRunningChromeVersion(client: any): Promise<ChromeVersion> {
try {
const { product } = await client.send('Browser.getVersion');
const parsed = parseChromeVersion(String(product ?? ''));
if (parsed) return parsed;
} catch {
// fall through to fallback
}
console.warn(`[browser-profiles] Could not read Chrome version, assuming ${FALLBACK_CHROME_MAJOR}`);
return { major: FALLBACK_CHROME_MAJOR, full: `${FALLBACK_CHROME_MAJOR}.0.0.0` };
}

// Dynamic imports to handle ESM/CJS
let chromeLauncher: typeof import('chrome-launcher');
Expand Down Expand Up @@ -269,6 +291,100 @@ export async function autoDetectTimezone(proxy: ProxyConfig): Promise<string> {
return 'America/New_York'; // Default fallback
}

function buildProtectionScript(profile: StoredProfile): string {
const fp = profile.fingerprint;
return getAllProtectionScripts({
webrtc: true,
canvas: true,
webgl: fp?.webgl ?? true,
audio: true,
navigator: {
language: fp?.language || 'en-US',
platform: fp?.platform || 'Win32',
hardwareConcurrency: fp?.hardwareConcurrency || 8,
deviceMemory: fp?.deviceMemory || 8,
},
});
}

/**
* Apply UA, timezone and protection scripts to one page session.
* Must run before the page's first document is created.
*/
async function applyProfileToPage(
client: any,
sessionId: string,
profile: StoredProfile,
realVersion: ChromeVersion,
): Promise<void> {
const platform = profile.fingerprint?.platform || 'Win32';
const language = profile.fingerprint?.language || 'en-US';
const { userAgent, version } = resolveUserAgent(profile.fingerprint?.userAgent, platform, realVersion);

await client.send('Network.setUserAgentOverride', {
userAgent,
platform,
acceptLanguage: language,
userAgentMetadata: buildUserAgentMetadata(platform, version),
}, sessionId);

await client.send('Page.addScriptToEvaluateOnNewDocument', {
source: buildProtectionScript(profile),
}, sessionId);

await client.send('Emulation.setTimezoneOverride', {
timezoneId: profile.timezone || 'America/New_York',
}, sessionId);
}

/**
* Attach to the browser target and install the profile on every page target,
* including pages opened later by Puppeteer/Playwright or by the user.
*/
async function installProfileOnBrowser(client: any, profile: StoredProfile): Promise<void> {
const realVersion = await getRunningChromeVersion(client);
const platform = profile.fingerprint?.platform || 'Win32';
const { version, mismatch } = resolveUserAgent(profile.fingerprint?.userAgent, platform, realVersion);
if (mismatch) {
console.warn(`[browser-profiles] fingerprint.userAgent claims Chrome ${version.major} but the running browser is ${realVersion.major}; detectors compare these`);
}

client.on('Target.attachedToTarget', async (params: any) => {
const { sessionId, targetInfo } = params;
if (targetInfo?.type === 'page') {
try {
await applyProfileToPage(client, sessionId, profile, realVersion);
} catch (error) {
console.error('[browser-profiles] Failed to apply profile to page:', (error as Error).message);
}
}
await client.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => { });
});

await client.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: true,
});

if (profile.cookies && profile.cookies.length > 0) {
await client.send('Storage.setCookies', {
cookies: profile.cookies.map((cookie) => ({
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path || '/',
httpOnly: cookie.httpOnly || false,
secure: cookie.secure || false,
sameSite: cookie.sameSite || 'Lax',
...(cookie.expires ? { expires: cookie.expires } : {}),
})),
}).catch(() => {
// Ignore cookie errors
});
}
}

/**
* Options for launchChrome function
*/
Expand Down Expand Up @@ -467,102 +583,6 @@ export async function launchChrome(options: ChromeLaunchOptions): Promise<Launch

console.log(`Chrome launched on port ${chromeProcess.port}, PID: ${chromeProcess.pid}`);

// Connect via CDP with retry (Chrome needs time to initialize debugging port)
let client: any;
const cdpMaxRetries = 10;
const cdpRetryDelay = 300; // ms

for (let i = 0; i < cdpMaxRetries; i++) {
try {
client = await (CDP as any)({ port: chromeProcess.port });
break;
} catch (cdpError: any) {
if (i === cdpMaxRetries - 1) {
// Last retry failed, cleanup and throw
console.error(`[browser-profiles] Failed to connect CDP after ${cdpMaxRetries} retries`);
try {
await chromeProcess.kill();
if (anonymizedProxyUrl) {
await proxyChain.closeAnonymizedProxy(anonymizedProxyUrl, true).catch(() => { });
}
} catch { }
throw cdpError;
}
// Wait and retry
await new Promise(r => setTimeout(r, cdpRetryDelay));
}
}

const { Network, Emulation, Page } = client;

// Enable network and inject anti-fingerprint scripts
await Network.enable();

// Set User-Agent override with platform spoofing (KEY: This is how puppeteer-extra-stealth does it!)
const userAgent = profile.fingerprint?.userAgent ||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const platform = profile.fingerprint?.platform || 'Win32';
const language = profile.fingerprint?.language || 'en-US';

await Network.setUserAgentOverride({
userAgent,
platform,
acceptLanguage: language,
userAgentMetadata: {
brands: [
{ brand: 'Not_A Brand', version: '8' },
{ brand: 'Chromium', version: '120' },
{ brand: 'Google Chrome', version: '120' },
],
fullVersion: '120.0.0.0',
platform: platform.includes('Win') ? 'Windows' : (platform.includes('Mac') ? 'macOS' : 'Linux'),
platformVersion: platform.includes('Win') ? '10.0.0' : '14.0.0',
architecture: 'x86',
model: '',
mobile: false,
},
});

// Inject fingerprint protection scripts
await Page.addScriptToEvaluateOnNewDocument({
source: getAllProtectionScripts({
webrtc: true,
canvas: true,
webgl: true,
audio: true,
navigator: {
language,
platform,
hardwareConcurrency: profile.fingerprint?.hardwareConcurrency || 8,
deviceMemory: profile.fingerprint?.deviceMemory || 8,
},
}),
});

// Set timezone
await Emulation.setTimezoneOverride({
timezoneId: profile.timezone || 'America/New_York',
});

// Inject cookies
if (profile.cookies && profile.cookies.length > 0) {
for (const cookie of profile.cookies) {
await Network.setCookie({
url: `https://${cookie.domain}`,
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
path: cookie.path || '/',
httpOnly: cookie.httpOnly || false,
secure: cookie.secure || false,
sameSite: cookie.sameSite || 'Lax',
...(cookie.expires ? { expires: cookie.expires } : {}),
}).catch(() => {
// Ignore cookie errors
});
}
}

// Get WebSocket endpoint with retry (browser needs time to fully initialize)
let versionInfo: { webSocketDebuggerUrl: string } | null = null;
const maxRetries = 10;
Expand Down Expand Up @@ -597,6 +617,26 @@ export async function launchChrome(options: ChromeLaunchOptions): Promise<Launch
throw new Error('Failed to get browser WebSocket endpoint after multiple retries');
}

// Attach to the browser target so every page (current and future, any
// context) gets the spoof before its first document runs. Attaching to a
// page target picked from /json/list is unreliable: Chrome lists internal
// browser_ui targets first.
let client: any;
try {
client = await (CDP as any)({ target: versionInfo.webSocketDebuggerUrl });
await installProfileOnBrowser(client, profile);
} catch (cdpError) {
console.error('[browser-profiles] Failed to attach to browser via CDP');
try {
await client?.close();
await chromeProcess.kill();
if (anonymizedProxyUrl) {
await proxyChain.closeAnonymizedProxy(anonymizedProxyUrl, true).catch(() => { });
}
} catch { }
throw cdpError;
}

// Track running browser
runningBrowsers.set(profile.id, {
process: chromeProcess,
Expand Down
Loading
Loading