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