diff --git a/packages/daemon/src/__tests__/stealth-headers.test.ts b/packages/daemon/src/__tests__/stealth-headers.test.ts new file mode 100644 index 0000000..81d2506 --- /dev/null +++ b/packages/daemon/src/__tests__/stealth-headers.test.ts @@ -0,0 +1,171 @@ +// Integration tests for injectHeaderStealth / 隐身请求头注入集成测试 +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium } from 'playwright-extra'; +import StealthPlugin from 'puppeteer-extra-plugin-stealth'; +import http from 'http'; +import os from 'os'; +import type { AddressInfo } from 'net'; +import type { Browser, BrowserContext, Page } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; +import { injectHeaderStealth } from '../stealth/headers.js'; + +chromium.use(StealthPlugin()); + +const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip; + +function getNonLoopbackIPv4(): string | null { + const nets = os.networkInterfaces(); + for (const name of Object.keys(nets)) { + const iface = nets[name]; + if (!iface) continue; + for (const net of iface) { + if (net.family === 'IPv4' && !net.internal) { + return net.address; + } + } + } + return null; +} + +const testProfile: FingerprintProfile = { + id: 'fp_stealth_hdr', + name: 'Stealth Headers Test', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + platform: 'Win32', + languages: ['en-US', 'en'], + acceptLanguage: 'en-US,en;q=0.9', + screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 }, + viewport: { width: 1280, height: 720 }, + webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' }, + timezone: 'America/New_York', + permissions: { + notifications: 'denied', + geolocation: 'granted', + camera: 'denied', + microphone: 'denied', + }, + behavior: { + mouseMoveDelay: { min: 50, max: 150 }, + keyPressDelay: { min: 80, max: 200 }, + scrollStepDelay: { min: 30, max: 100 }, + }, + canvasNoise: { enabled: true, strength: 0.5 }, +}; + +integration('injectHeaderStealth', () => { + let browser: Browser; + let context: BrowserContext; + let page: Page; + let server: http.Server; + let capturedHeaders: Record = {}; + let serverUrl: string; + + beforeAll(async () => { + server = http.createServer((req, res) => { + capturedHeaders = { ...req.headers }; + if (req.url === '/check') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(capturedHeaders)); + } else { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + `); + } + }); + + await new Promise((resolve) => { + // Bind to 0.0.0.0 so the browser can reach it from any non-loopback IP + server.listen(0, '0.0.0.0', resolve); + }); + + const port = (server.address() as AddressInfo).port; + + // Find a non-loopback IP so that the URL does not start with http://127.0.0.1 + // or http://localhost (those are explicitly skipped by injectHeaderStealth). + const nonLoopback = getNonLoopbackIPv4(); + if (nonLoopback) { + serverUrl = `http://${nonLoopback}:${port}`; + } else { + serverUrl = `http://127.0.0.1:${port}`; + } + + browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + }); + + afterAll(async () => { + if (page) await page.close(); + if (context) await context.close(); + if (browser) await browser.close(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('should inject sec-ch-ua headers on outgoing requests', async () => { + context = await browser.newContext(); + page = await context.newPage(); + + await injectHeaderStealth(page, testProfile); + + // Navigate to our test page which fetches /check + await page.goto(serverUrl); + + // Wait for the fetch to finish and the title to be updated + await page.waitForFunction( + () => document.title.startsWith('{') || document.title.startsWith('ERROR'), + { timeout: 10_000 }, + ); + + const title = await page.title(); + if (title.startsWith('ERROR')) { + throw new Error(`Test page fetch failed: ${title}`); + } + + const headers = JSON.parse(title) as Record; + + // sec-ch-ua should contain browser brand hints + const chUa = String(headers['sec-ch-ua'] ?? ''); + expect(chUa).toContain('"Chromium"'); + expect(chUa).toContain('"Google Chrome"'); + expect(chUa).toContain('"Not?A_Brand"'); + + // sec-ch-ua-platform should match the profile platform ("Win32" → "Windows") + expect(headers['sec-ch-ua-platform']).toBe('"Windows"'); + + // sec-ch-ua-mobile should be ?0 (desktop) + expect(headers['sec-ch-ua-mobile']).toBe('?0'); + + // sec-ch-ua-arch should match process.arch + expect(headers['sec-ch-ua-arch']).toBe(process.arch); + + // sec-ch-ua-bitness should be "64" + expect(headers['sec-ch-ua-bitness']).toBe('64'); + + // sec-ch-ua-full-version extracted from profile.userAgent Chrome/120.0.0.0 + expect(headers['sec-ch-ua-full-version']).toBe('120.0.0.0'); + }); + + it('should not inject headers on localhost traffic', async () => { + context = await browser.newContext(); + page = await context.newPage(); + + await injectHeaderStealth(page, testProfile); + + // Navigate directly to the check endpoint on localhost — should be bypassed + const localPort = (server.address() as AddressInfo).port; + await page.goto(`http://127.0.0.1:${localPort}/check`); + + const body = await page.evaluate(() => document.body.textContent ?? '{}'); + const headers = JSON.parse(body) as Record; + + // sec-ch-ua should NOT be injected on localhost + expect(headers['sec-ch-ua']).toBeUndefined(); + }); +}); diff --git a/packages/daemon/src/stealth/headers.ts b/packages/daemon/src/stealth/headers.ts new file mode 100644 index 0000000..98c36f4 --- /dev/null +++ b/packages/daemon/src/stealth/headers.ts @@ -0,0 +1,54 @@ +// Stealth — HTTP request header injection / 隐身 — HTTP 请求头注入 +// Injects Sec-CH-UA family headers to match a browser profile. +// 注入 Sec-CH-UA 系列请求头以匹配浏览器配置。 +import type { Page } from 'playwright'; +import type { FingerprintProfile } from '@visionl/core'; + +/** Extract Chrome major version from userAgent string / 从 userAgent 提取 Chrome 主版本号 */ +function extractChromeVersion(ua: string): { major: string; full: string } { + const m = ua.match(/Chrome\/(\d+)\.(\d+)\.(\d+)\.(\d+)/); + if (m) { + return { + major: m[1], + full: `${m[1]}.${m[2]}.${m[3]}.${m[4]}`, + }; + } + return { major: '132', full: '132.0.6834.160' }; +} + +/** Resolve platform string for Sec-CH-UA-Platform / 根据 profile.platform 推导平台字符串 */ +function resolvePlatform(platform: string): string { + if (platform.includes('Windows')) return 'Windows'; + if (platform.includes('Mac')) return 'macOS'; + return 'Linux'; +} + +export async function injectHeaderStealth(page: Page, profile: FingerprintProfile): Promise { + const { major, full: fullVersion } = extractChromeVersion(profile.userAgent); + const platform = resolvePlatform(profile.platform); + const arch = process.arch; // aarch64 / x64 / ... + + await page.route('**/*', (route) => { + const request = route.request(); + const url = request.url(); + + // Skip WebSocket upgrades and daemon-internal traffic / 跳过 WebSocket 升级和守护进程内部流量 + if (url.startsWith('ws://') || url.startsWith('wss://')) { + return route.continue(); + } + // Do NOT intercept requests to localhost / 127.0.0.1 (health endpoint etc.) + if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) { + return route.continue(); + } + + const headers = { ...request.headers() }; + headers['sec-ch-ua'] = `"Chromium";v="${major}", "Google Chrome";v="${major}", "Not?A_Brand";v="99"`; + headers['sec-ch-ua-platform'] = `"${platform}"`; + headers['sec-ch-ua-mobile'] = '?0'; + headers['sec-ch-ua-arch'] = arch; + headers['sec-ch-ua-bitness'] = '64'; + headers['sec-ch-ua-full-version'] = fullVersion; + + route.continue({ headers }); + }); +}