feat(daemon): add HTTP header stealth injection (Task 14)
Inject Sec-CH-UA family headers on outgoing page requests:
- sec-ch-ua, sec-ch-ua-platform, sec-ch-ua-mobile
- sec-ch-ua-arch (from process.arch)
- sec-ch-ua-bitness ('64')
- sec-ch-ua-full-version (extracted from profile.userAgent)
Skips localhost/127.0.0.1 traffic (daemon internal) and WebSocket upgrades.
This commit is contained in:
@@ -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<string, string | string[] | undefined> = {};
|
||||
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(`<!DOCTYPE html><html><body>
|
||||
<script>
|
||||
fetch('/check')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(h) { document.title = JSON.stringify(h); })
|
||||
.catch(function(e) { document.title = 'ERROR:' + e.message; });
|
||||
</script>
|
||||
</body></html>`);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((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<void>((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<string, string | string[] | undefined>;
|
||||
|
||||
// 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<string, string | string[] | undefined>;
|
||||
|
||||
// sec-ch-ua should NOT be injected on localhost
|
||||
expect(headers['sec-ch-ua']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user