413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { BrowserManager } from './browser.js';
|
|
|
|
async function readWebdriverSignals(browser: BrowserManager): Promise<{
|
|
value: boolean | undefined;
|
|
inNavigator: boolean;
|
|
ownNavigator: boolean;
|
|
ownPrototype: boolean;
|
|
}> {
|
|
const page = browser.getPage();
|
|
await page.goto('about:blank');
|
|
return page.evaluate(() => {
|
|
const prototype = Object.getPrototypeOf(navigator);
|
|
return {
|
|
value: navigator.webdriver,
|
|
inNavigator: 'webdriver' in navigator,
|
|
ownNavigator: Object.prototype.hasOwnProperty.call(navigator, 'webdriver'),
|
|
ownPrototype: Object.prototype.hasOwnProperty.call(prototype, 'webdriver'),
|
|
};
|
|
});
|
|
}
|
|
|
|
describe('Stealth mode', () => {
|
|
let browser: BrowserManager;
|
|
|
|
afterEach(async () => {
|
|
if (browser?.isLaunched()) {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
it('removes navigator.webdriver when stealth is enabled', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await readWebdriverSignals(browser);
|
|
expect(signals.value).toBeUndefined();
|
|
expect(signals.inNavigator).toBe(false);
|
|
expect(signals.ownNavigator).toBe(false);
|
|
expect(signals.ownPrototype).toBe(false);
|
|
});
|
|
|
|
it('applies stealth patches to contexts created by newWindow', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
await browser.newWindow();
|
|
|
|
const signals = await readWebdriverSignals(browser);
|
|
expect(signals.value).toBeUndefined();
|
|
expect(signals.inNavigator).toBe(false);
|
|
expect(signals.ownNavigator).toBe(false);
|
|
expect(signals.ownPrototype).toBe(false);
|
|
});
|
|
|
|
it('aligns navigator language with AGENT_BROWSER_LOCALE', async () => {
|
|
const previousLocale = process.env.AGENT_BROWSER_LOCALE;
|
|
process.env.AGENT_BROWSER_LOCALE = 'fr-FR';
|
|
|
|
try {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const languageSignals = await browser.getPage().evaluate(() => ({
|
|
language: navigator.language,
|
|
languages: navigator.languages,
|
|
}));
|
|
|
|
expect(languageSignals.language).toBe('fr-FR');
|
|
expect(languageSignals.languages).toEqual(['fr-FR', 'fr']);
|
|
} finally {
|
|
if (previousLocale === undefined) {
|
|
delete process.env.AGENT_BROWSER_LOCALE;
|
|
} else {
|
|
process.env.AGENT_BROWSER_LOCALE = previousLocale;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps navigator.vendor aligned with Chrome', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const vendorSignals = await browser.getPage().evaluate(() => ({
|
|
userAgent: navigator.userAgent,
|
|
vendor: navigator.vendor,
|
|
}));
|
|
|
|
if (vendorSignals.userAgent.includes('Chrome/')) {
|
|
expect(vendorSignals.vendor).toBe('Google Inc.');
|
|
}
|
|
});
|
|
|
|
it('keeps worker and page userAgent free of HeadlessChrome tokens', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const userAgentSignals = await browser.getPage().evaluate(async () => {
|
|
const pageUA = navigator.userAgent;
|
|
const workerUA = await new Promise<string>((resolve) => {
|
|
const source = 'postMessage(navigator.userAgent);';
|
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
const worker = new Worker(URL.createObjectURL(blob));
|
|
worker.onmessage = (event) => resolve(String(event.data));
|
|
});
|
|
return { pageUA, workerUA };
|
|
});
|
|
|
|
expect(userAgentSignals.pageUA).not.toContain('HeadlessChrome');
|
|
expect(userAgentSignals.workerUA).not.toContain('HeadlessChrome');
|
|
});
|
|
|
|
it('neutralizes the css webdriver heuristic probe', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => ({
|
|
probe: CSS.supports('border-end-end-radius: initial'),
|
|
baseline: CSS.supports('display: block'),
|
|
webdriver: navigator.webdriver,
|
|
inNavigator: 'webdriver' in navigator,
|
|
}));
|
|
|
|
expect(signals.probe).toBe(false);
|
|
expect(signals.baseline).toBe(true);
|
|
expect(signals.webdriver).toBeUndefined();
|
|
expect(signals.inNavigator).toBe(false);
|
|
});
|
|
|
|
it('neutralizes creepjs prefers-color-scheme light probe', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => {
|
|
const node = document.createElement('div');
|
|
node.setAttribute('style', 'background-color: ActiveText');
|
|
document.body.appendChild(node);
|
|
const activeTextColor = getComputedStyle(node).backgroundColor;
|
|
node.remove();
|
|
|
|
return {
|
|
activeTextColor,
|
|
prefersLight: matchMedia('(prefers-color-scheme: light)').matches,
|
|
prefersDark: matchMedia('(prefers-color-scheme: dark)').matches,
|
|
lightListenerCalls: (() => {
|
|
try {
|
|
const mql = matchMedia('(prefers-color-scheme: light)');
|
|
const handler = () => {};
|
|
mql.addEventListener('change', handler);
|
|
mql.removeEventListener('change', handler);
|
|
return 'ok';
|
|
} catch (error) {
|
|
return String(error);
|
|
}
|
|
})(),
|
|
};
|
|
});
|
|
|
|
expect(signals.activeTextColor).not.toBe('rgb(255, 0, 0)');
|
|
expect(signals.prefersLight).toBe(false);
|
|
expect(typeof signals.prefersDark).toBe('boolean');
|
|
expect(signals.lightListenerCalls).toBe('ok');
|
|
});
|
|
|
|
it('exposes realistic mimeTypes/pdf/share signals', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => ({
|
|
mimeTypesLength: navigator.mimeTypes ? navigator.mimeTypes.length : 0,
|
|
pdfViewerEnabled: navigator.pdfViewerEnabled,
|
|
hasShare: typeof navigator.share === 'function',
|
|
hasCanShare: typeof navigator.canShare === 'function',
|
|
hasConnectionDownlinkMax:
|
|
!!navigator.connection && typeof navigator.connection.downlinkMax === 'number',
|
|
hasConnectionDownlinkMaxOnProto:
|
|
!!navigator.connection && 'downlinkMax' in Object.getPrototypeOf(navigator.connection),
|
|
}));
|
|
|
|
expect(signals.mimeTypesLength).toBeGreaterThan(0);
|
|
expect(signals.pdfViewerEnabled).toBe(true);
|
|
expect(signals.hasShare).toBe(true);
|
|
expect(signals.hasCanShare).toBe(true);
|
|
expect(signals.hasConnectionDownlinkMax).toBe(true);
|
|
expect(signals.hasConnectionDownlinkMaxOnProto).toBe(true);
|
|
});
|
|
|
|
it('exposes legacy chrome.app/csi/loadTimes APIs', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => {
|
|
const chromeObj = (window as any).chrome;
|
|
const csi = chromeObj && typeof chromeObj.csi === 'function' ? chromeObj.csi() : null;
|
|
const loadTimes =
|
|
chromeObj && typeof chromeObj.loadTimes === 'function' ? chromeObj.loadTimes() : null;
|
|
return {
|
|
hasChrome: !!chromeObj,
|
|
hasApp: !!(chromeObj && chromeObj.app),
|
|
appInstalled: chromeObj?.app?.isInstalled,
|
|
appRunningState: chromeObj?.app?.runningState?.(),
|
|
hasCsi: typeof chromeObj?.csi === 'function',
|
|
hasLoadTimes: typeof chromeObj?.loadTimes === 'function',
|
|
csiHasOnloadT: csi && typeof csi.onloadT === 'number',
|
|
csiHasPageT: csi && typeof csi.pageT === 'number',
|
|
loadTimesHasRequestTime: loadTimes && typeof loadTimes.requestTime === 'number',
|
|
loadTimesHasConnectionInfo: loadTimes && typeof loadTimes.connectionInfo === 'string',
|
|
};
|
|
});
|
|
|
|
expect(signals.hasChrome).toBe(true);
|
|
expect(signals.hasApp).toBe(true);
|
|
expect(signals.appInstalled).toBe(false);
|
|
expect(signals.appRunningState).toBe('cannot_run');
|
|
expect(signals.hasCsi).toBe(true);
|
|
expect(signals.hasLoadTimes).toBe(true);
|
|
expect(signals.csiHasOnloadT).toBe(true);
|
|
expect(signals.csiHasPageT).toBe(true);
|
|
expect(signals.loadTimesHasRequestTime).toBe(true);
|
|
expect(signals.loadTimesHasConnectionInfo).toBe(true);
|
|
});
|
|
|
|
it('spoofs high-signal media codec probes', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const codecs = await browser.getPage().evaluate(() => {
|
|
const video = document.createElement('video');
|
|
const audio = document.createElement('audio');
|
|
return {
|
|
mp4Avc: video.canPlayType('video/mp4; codecs="avc1.42E01E"'),
|
|
xM4a: audio.canPlayType('audio/x-m4a;'),
|
|
aac: audio.canPlayType('audio/aac'),
|
|
};
|
|
});
|
|
|
|
expect(codecs.mp4Avc).toBe('probably');
|
|
expect(codecs.xM4a).toBe('maybe');
|
|
expect(codecs.aac).toBe('probably');
|
|
});
|
|
|
|
it('patches srcdoc iframe.contentWindow probes', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const iframeSignals = await browser.getPage().evaluate(() => {
|
|
const iframe = document.createElement('iframe');
|
|
iframe.srcdoc = '<!doctype html><html><body>ok</body></html>';
|
|
const win = iframe.contentWindow;
|
|
return {
|
|
hasContentWindow: !!win,
|
|
selfEqualsWindow: win ? win.self === win : false,
|
|
selfEqualsTop: win ? win.self === window.top : null,
|
|
frameElementMatches: win ? win.frameElement === iframe : false,
|
|
zeroSlotType: typeof (win as any)?.[0],
|
|
};
|
|
});
|
|
|
|
expect(iframeSignals.hasContentWindow).toBe(true);
|
|
expect(iframeSignals.selfEqualsWindow).toBe(true);
|
|
expect(iframeSignals.selfEqualsTop).toBe(false);
|
|
expect(iframeSignals.frameElementMatches).toBe(true);
|
|
expect(iframeSignals.zeroSlotType).toBe('undefined');
|
|
});
|
|
|
|
it('sanitizes Playwright sourceURL markers in error stacks', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const stacks = await browser.getPage().evaluate(() => {
|
|
const explicitEvalStack = eval(
|
|
`(() => { try { throw new Error('explicit'); } catch (error) { return String(error.stack || ''); } })()\n//# sourceURL=__playwright_evaluation_script__`
|
|
);
|
|
let directStack = '';
|
|
try {
|
|
throw new Error('direct');
|
|
} catch (error) {
|
|
directStack = String((error as Error).stack || '');
|
|
}
|
|
return { explicitEvalStack, directStack };
|
|
});
|
|
|
|
expect(stacks.explicitEvalStack).not.toContain('__playwright_evaluation_script__');
|
|
expect(stacks.explicitEvalStack).not.toContain('__puppeteer_evaluation_script__');
|
|
expect(stacks.explicitEvalStack).not.toContain('sourceURL=');
|
|
expect(stacks.directStack).not.toContain('__playwright_evaluation_script__');
|
|
expect(stacks.directStack).not.toContain('__puppeteer_evaluation_script__');
|
|
});
|
|
|
|
it('sanitizes sourceURL markers in direct CDP Runtime.evaluate payloads', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const cdp = await browser.getCDPSession();
|
|
const response = await cdp.send('Runtime.evaluate', {
|
|
expression:
|
|
"(() => { throw new Error('cdp'); })()\\n//# sourceURL=__playwright_evaluation_script__",
|
|
returnByValue: true,
|
|
});
|
|
const raw = JSON.stringify(response);
|
|
expect(raw).not.toContain('__playwright_evaluation_script__');
|
|
expect(raw).not.toContain('__puppeteer_evaluation_script__');
|
|
expect(raw).not.toContain('sourceURL=');
|
|
});
|
|
|
|
it('doctor reports CDP sourceURL probe as pass in launched chromium sessions', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const report = await browser.runDoctor();
|
|
const check = report.checks.find((entry) => entry.name === 'cdp:sourceurl-sanitized');
|
|
|
|
expect(check).toBeDefined();
|
|
expect(check?.status).toBe('pass');
|
|
});
|
|
|
|
it('doctor marks plugin handshake context as skip outside CDP mode', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const report = await browser.runDoctor();
|
|
const check = report.checks.find((entry) => entry.name === 'plugin:handshake-context');
|
|
|
|
expect(check).toBeDefined();
|
|
expect(check?.status).toBe('skip');
|
|
expect(check?.message).toContain('only applies to CDP');
|
|
});
|
|
|
|
it('exposes contacts manager and content index APIs', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => ({
|
|
hasContacts: 'contacts' in navigator,
|
|
contactsManagerCtor: typeof (window as any).ContactsManager === 'function',
|
|
hasContentIndexCtor: typeof (window as any).ContentIndex === 'function',
|
|
hasServiceWorkerRegistration: typeof ServiceWorkerRegistration !== 'undefined',
|
|
hasContentIndexOnSWR:
|
|
typeof ServiceWorkerRegistration !== 'undefined' &&
|
|
('contentIndex' in ServiceWorkerRegistration.prototype ||
|
|
'index' in ServiceWorkerRegistration.prototype),
|
|
notificationPermission: typeof Notification !== 'undefined' ? Notification.permission : null,
|
|
screenMatchesViewport:
|
|
screen.width === window.innerWidth && screen.height === window.innerHeight,
|
|
}));
|
|
|
|
expect(signals.hasContacts).toBe(true);
|
|
expect(signals.contactsManagerCtor).toBe(true);
|
|
expect(signals.hasContentIndexCtor).toBe(true);
|
|
if (signals.hasServiceWorkerRegistration) {
|
|
expect(signals.hasContentIndexOnSWR).toBe(true);
|
|
}
|
|
expect(signals.notificationPermission).toBe('default');
|
|
expect(signals.screenMatchesViewport).toBe(false);
|
|
});
|
|
|
|
it('exposes downlinkMax inside dedicated workers', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const workerSignals = await browser.getPage().evaluate(async () => {
|
|
return new Promise<{
|
|
hasConnection: boolean;
|
|
hasDownlinkMax: boolean;
|
|
hasDownlinkMaxOnProto: boolean;
|
|
downlinkMax: unknown;
|
|
}>((resolve) => {
|
|
const source =
|
|
"postMessage({hasConnection: !!navigator.connection, hasDownlinkMax: navigator.connection ? ('downlinkMax' in navigator.connection) : false, hasDownlinkMaxOnProto: navigator.connection ? ('downlinkMax' in Object.getPrototypeOf(navigator.connection)) : false, downlinkMax: navigator.connection && navigator.connection.downlinkMax});";
|
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
const worker = new Worker(URL.createObjectURL(blob));
|
|
worker.onmessage = (event) => resolve(event.data);
|
|
});
|
|
});
|
|
|
|
expect(workerSignals.hasConnection).toBe(true);
|
|
expect(workerSignals.hasDownlinkMax).toBe(true);
|
|
expect(workerSignals.hasDownlinkMaxOnProto).toBe(true);
|
|
expect(typeof workerSignals.downlinkMax).toBe('number');
|
|
});
|
|
|
|
it('skips worker wrapping for cross-origin blob URLs', async () => {
|
|
browser = new BrowserManager();
|
|
await browser.launch({ headless: true, stealth: true });
|
|
|
|
const signals = await browser.getPage().evaluate(() => {
|
|
const nativeCreateObjectURL = URL.createObjectURL;
|
|
const nativeRevokeObjectURL = URL.revokeObjectURL;
|
|
let createCalls = 0;
|
|
let revokeCalls = 0;
|
|
|
|
(URL as any).createObjectURL = (...args: unknown[]) => {
|
|
createCalls += 1;
|
|
return nativeCreateObjectURL.apply(URL, args as [Blob | MediaSource]);
|
|
};
|
|
(URL as any).revokeObjectURL = (...args: unknown[]) => {
|
|
revokeCalls += 1;
|
|
return nativeRevokeObjectURL.apply(URL, args as [string]);
|
|
};
|
|
|
|
try {
|
|
new Worker('blob:https://challenges.cloudflare.com/11111111-1111-1111-1111-111111111111');
|
|
} catch {}
|
|
|
|
(URL as any).createObjectURL = nativeCreateObjectURL;
|
|
(URL as any).revokeObjectURL = nativeRevokeObjectURL;
|
|
return { createCalls, revokeCalls };
|
|
});
|
|
|
|
expect(signals.createCalls).toBe(0);
|
|
expect(signals.revokeCalls).toBe(0);
|
|
});
|
|
});
|