feat: add stealth modules for chrome runtime, screen, and permissions (Task 12)
- chrome-runtime.ts: injects window.chrome object with runtime, loadTimes, csi, app - screen.ts: overrides screen.width/height/avail/colorDepth, window.outerWidth/Height - permissions.ts: hooks navigator.permissions.query() for 4 permission types - tests: 15 integration tests (6 chrome, 6 screen, 5 permissions), skip when VISIONL_INTEGRATION != '1' - typecheck: passed (0 errors)
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
// Integration tests for chrome-runtime, screen, and permissions stealth modules / chrome-runtime、screen、permissions 隐身模块集成测试
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import type { Browser, BrowserContext } from 'playwright';
|
||||
import type { FingerprintProfile } from '@visionl/core';
|
||||
import { injectChromeRuntime } from '../stealth/chrome-runtime.js';
|
||||
import { injectScreenStealth } from '../stealth/screen.js';
|
||||
import { injectPermissionsStealth } from '../stealth/permissions.js';
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
const integration = process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
|
||||
|
||||
const testProfile: FingerprintProfile = {
|
||||
id: 'fp_stealth_chrome_screen',
|
||||
name: 'Stealth Chrome+Screen+Permissions 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 },
|
||||
};
|
||||
|
||||
// ==================== chrome-runtime / Chrome 运行时注入测试 ====================
|
||||
integration('injectChromeRuntime', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should set typeof window.chrome to "object"', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const result = await page.evaluate(() => typeof (window as any).chrome);
|
||||
expect(result).toBe('object');
|
||||
});
|
||||
|
||||
it('should have chrome.runtime defined', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const hasRuntime = await page.evaluate(() => !!(window as any).chrome.runtime);
|
||||
expect(hasRuntime).toBe(true);
|
||||
});
|
||||
|
||||
it('should have chrome.loadTimes defined as function', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const loadTimesType = await page.evaluate(() => typeof (window as any).chrome.loadTimes);
|
||||
expect(loadTimesType).toBe('function');
|
||||
});
|
||||
|
||||
it('should have chrome.csi defined as function', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const csiType = await page.evaluate(() => typeof (window as any).chrome.csi);
|
||||
expect(csiType).toBe('function');
|
||||
});
|
||||
|
||||
it('should have chrome.app defined as object', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const appType = await page.evaluate(() => typeof (window as any).chrome.app);
|
||||
expect(appType).toBe('object');
|
||||
});
|
||||
|
||||
it('should have chrome.app.isInstalled === false', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectChromeRuntime(context);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const isInstalled = await page.evaluate(() => (window as any).chrome.app.isInstalled);
|
||||
expect(isInstalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== screen / 屏幕属性隐身测试 ====================
|
||||
integration('injectScreenStealth', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should set screen.width from profile', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const width = await page.evaluate(() => screen.width);
|
||||
expect(width).toBe(testProfile.screen.width);
|
||||
});
|
||||
|
||||
it('should set screen.height from profile', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const height = await page.evaluate(() => screen.height);
|
||||
expect(height).toBe(testProfile.screen.height);
|
||||
});
|
||||
|
||||
it('should set screen.availHeight < screen.height (taskbar subtracted)', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const { height, availHeight } = await page.evaluate(() => ({
|
||||
height: screen.height,
|
||||
availHeight: screen.availHeight,
|
||||
}));
|
||||
expect(availHeight).toBeLessThan(height);
|
||||
});
|
||||
|
||||
it('should set screen.colorDepth from profile', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const colorDepth = await page.evaluate(() => screen.colorDepth);
|
||||
expect(colorDepth).toBe(testProfile.screen.colorDepth);
|
||||
});
|
||||
|
||||
it('should set window.outerWidth > window.innerWidth (decorations)', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const { outer, inner } = await page.evaluate(() => ({
|
||||
outer: window.outerWidth,
|
||||
inner: window.innerWidth,
|
||||
}));
|
||||
expect(outer).toBeGreaterThan(inner);
|
||||
});
|
||||
|
||||
it('should set window.outerHeight > window.innerHeight (decorations)', async () => {
|
||||
context = await browser.newContext({ viewport: testProfile.viewport });
|
||||
await injectScreenStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const { outer, inner } = await page.evaluate(() => ({
|
||||
outer: window.outerHeight,
|
||||
inner: window.innerHeight,
|
||||
}));
|
||||
expect(outer).toBeGreaterThan(inner);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== permissions / 权限查询隐身测试 ====================
|
||||
integration('injectPermissionsStealth', () => {
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should override notifications permission from profile', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectPermissionsStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const status = await page.evaluate(async () => {
|
||||
const result = await navigator.permissions.query({ name: 'notifications' });
|
||||
return result.state;
|
||||
});
|
||||
expect(status).toBe(testProfile.permissions.notifications);
|
||||
});
|
||||
|
||||
it('should override geolocation permission from profile', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectPermissionsStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const status = await page.evaluate(async () => {
|
||||
const result = await navigator.permissions.query({ name: 'geolocation' });
|
||||
return result.state;
|
||||
});
|
||||
expect(status).toBe(testProfile.permissions.geolocation);
|
||||
});
|
||||
|
||||
it('should override camera permission from profile', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectPermissionsStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const status = await page.evaluate(async () => {
|
||||
const result = await navigator.permissions.query({ name: 'camera' });
|
||||
return result.state;
|
||||
});
|
||||
expect(status).toBe(testProfile.permissions.camera);
|
||||
});
|
||||
|
||||
it('should override microphone permission from profile', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectPermissionsStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const status = await page.evaluate(async () => {
|
||||
const result = await navigator.permissions.query({ name: 'microphone' });
|
||||
return result.state;
|
||||
});
|
||||
expect(status).toBe(testProfile.permissions.microphone);
|
||||
});
|
||||
|
||||
it('should not intercept unknown permission names', async () => {
|
||||
context = await browser.newContext();
|
||||
await injectPermissionsStealth(context, testProfile);
|
||||
const page = await context.newPage();
|
||||
await page.goto('about:blank');
|
||||
|
||||
const shouldThrow = await page.evaluate(async () => {
|
||||
try {
|
||||
await navigator.permissions.query({ name: 'unknown-perm' as any });
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
expect(shouldThrow).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Stealth — inject window.chrome object for headless detection avoidance / 隐身 — 注入 window.chrome 对象以规避无头检测
|
||||
import type { BrowserContext } from 'playwright';
|
||||
|
||||
export async function injectChromeRuntime(context: BrowserContext): Promise<void> {
|
||||
await context.addInitScript(() => {
|
||||
// Headless Chrome has typeof window.chrome === 'undefined' — inject it / 无头模式下缺少 chrome 对象,注入以伪装真实 Chrome
|
||||
const win = window as any;
|
||||
|
||||
if (typeof win.chrome === 'undefined') {
|
||||
win.chrome = {};
|
||||
}
|
||||
|
||||
if (!win.chrome.runtime) {
|
||||
win.chrome.runtime = {
|
||||
PlatformOs: { mac: 'mac', win: 'win', android: 'android', cros: 'cros', linux: 'linux', openbsd: 'openbsd', fuchsia: 'fuchsia' },
|
||||
PlatformArch: { arm: 'arm', arm64: 'arm64', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' },
|
||||
PlatformNaclArch: { arm: 'arm', x86_32: 'x86-32', x86_64: 'x86-64', mips: 'mips', mips64: 'mips64' },
|
||||
RequestUpdateCheckStatus: { throttled: 'throttled', no_update: 'no_update', update_available: 'update_available' },
|
||||
OnInstalledReason: { install: 'install', update: 'update', chrome_update: 'chrome_update', shared_module_update: 'shared_module_update' },
|
||||
OnRestartRequiredReason: { app_update: 'app_update', os_update: 'os_update', periodic: 'periodic' },
|
||||
id: void 0,
|
||||
getManifest(): object { return { version: '0.0.0', name: '', manifest_version: 3 }; },
|
||||
getURL(path: string): string { return `chrome-extension://invalid/${path}`; },
|
||||
lastError: void 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (!win.chrome.loadTimes) {
|
||||
win.chrome.loadTimes = () => ({
|
||||
requestTime: Date.now() / 1000,
|
||||
startLoadTime: Date.now() / 1000,
|
||||
commitLoadTime: Date.now() / 1000,
|
||||
finishDocumentLoadTime: Date.now() / 1000,
|
||||
finishLoadTime: Date.now() / 1000,
|
||||
firstPaintTime: Date.now() / 1000,
|
||||
firstPaintAfterLoadTime: 0,
|
||||
navigationType: 'Other',
|
||||
wasFetchedViaSpdy: true,
|
||||
wasNpnNegotiated: true,
|
||||
npnNegotiatedProtocol: 'http/1.1',
|
||||
wasAlternateProtocolAvailable: false,
|
||||
connectionInfo: 'http/1.1',
|
||||
});
|
||||
}
|
||||
|
||||
if (!win.chrome.csi) {
|
||||
win.chrome.csi = () => ({
|
||||
startE: 0,
|
||||
onloadT: 0,
|
||||
pageT: 0,
|
||||
tran: 15,
|
||||
});
|
||||
}
|
||||
|
||||
if (!win.chrome.app) {
|
||||
win.chrome.app = {
|
||||
isInstalled: false,
|
||||
InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
|
||||
RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
|
||||
getDetails(): Array<never> { return []; },
|
||||
getIsInstalled(): boolean { return false; },
|
||||
runningState(): string { return 'cannot_run'; },
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Stealth — override navigator.permissions.query() at JS level / 隐身 — 在 JS 层面覆盖 navigator.permissions.query()
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { FingerprintProfile, PermissionState } from '@visionl/core';
|
||||
|
||||
const WATCHED_NAMES = ['notifications', 'geolocation', 'camera', 'microphone'] as const;
|
||||
|
||||
export async function injectPermissionsStealth(
|
||||
context: BrowserContext,
|
||||
profile: FingerprintProfile,
|
||||
): Promise<void> {
|
||||
const perms: Record<string, PermissionState> = {};
|
||||
|
||||
for (const name of WATCHED_NAMES) {
|
||||
perms[name] = profile.permissions[name];
|
||||
}
|
||||
|
||||
await context.addInitScript((opts) => {
|
||||
const permissionsNameSet = new Set<string>(opts.watchedNames);
|
||||
const permissionsMap: Record<string, string> = opts.stateMap;
|
||||
|
||||
const origQuery = navigator.permissions.query.bind(navigator.permissions);
|
||||
|
||||
navigator.permissions.query = function (descriptor: PermissionDescriptor): Promise<PermissionStatus> {
|
||||
const name = descriptor.name;
|
||||
|
||||
// Intercept watched permission names only / 仅拦截关注的权限名称
|
||||
if (permissionsNameSet.has(name) && name in permissionsMap) {
|
||||
return Promise.resolve({
|
||||
name,
|
||||
state: permissionsMap[name],
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent(): boolean { return true; },
|
||||
} as PermissionStatus);
|
||||
}
|
||||
|
||||
// Pass through other queries / 透传其他权限查询
|
||||
return origQuery(descriptor);
|
||||
};
|
||||
}, {
|
||||
watchedNames: [...WATCHED_NAMES],
|
||||
stateMap: perms,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Stealth — override screen and window dimension properties / 隐身 — 覆盖屏幕和窗口尺寸属性
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { FingerprintProfile } from '@visionl/core';
|
||||
|
||||
const TASKBAR_HEIGHT = 40; // typical taskbar height in px / 典型任务栏高度(像素)
|
||||
|
||||
export async function injectScreenStealth(
|
||||
context: BrowserContext,
|
||||
profile: FingerprintProfile,
|
||||
): Promise<void> {
|
||||
const { screen: screenProfile, viewport } = profile;
|
||||
|
||||
await context.addInitScript((opts) => {
|
||||
// Override screen object / 覆盖 screen 对象
|
||||
Object.defineProperty(screen, 'width', {
|
||||
get: () => opts.screenWidth,
|
||||
});
|
||||
Object.defineProperty(screen, 'height', {
|
||||
get: () => opts.screenHeight,
|
||||
});
|
||||
Object.defineProperty(screen, 'availWidth', {
|
||||
get: () => opts.availWidth,
|
||||
});
|
||||
Object.defineProperty(screen, 'availHeight', {
|
||||
get: () => opts.availHeight,
|
||||
});
|
||||
Object.defineProperty(screen, 'colorDepth', {
|
||||
get: () => opts.colorDepth,
|
||||
});
|
||||
|
||||
// Override window.outerWidth/Height > inner for window decorations / 覆盖 outerWidth/Height 使其 > inner 模拟窗口装饰
|
||||
Object.defineProperty(window, 'outerWidth', {
|
||||
get: () => opts.outerWidth,
|
||||
});
|
||||
Object.defineProperty(window, 'outerHeight', {
|
||||
get: () => opts.outerHeight,
|
||||
});
|
||||
}, {
|
||||
screenWidth: screenProfile.width,
|
||||
screenHeight: screenProfile.height,
|
||||
availWidth: screenProfile.width,
|
||||
availHeight: screenProfile.height - TASKBAR_HEIGHT,
|
||||
colorDepth: screenProfile.colorDepth,
|
||||
outerWidth: viewport.width + 16, // window frame chrome / 窗口边框
|
||||
outerHeight: viewport.height + 72, // title bar + window frame / 标题栏 + 窗口边框
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user