diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 4b7dabf1..9ad31ab7 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -188,7 +188,7 @@ export async function applyStealthPatches( } // ======================================== - // 5. LANGUAGES + // 5. LANGUAGES + PLATFORM // ======================================== Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'], @@ -196,6 +196,16 @@ export async function applyStealthPatches( configurable: true, }); + // Platform must match the user agent. If UA says Mac, platform must be MacIntel. + // navigator.platform is 'Linux x86_64' in containers which contradicts a Mac UA. + if (navigator.userAgent.includes('Macintosh')) { + Object.defineProperty(navigator, 'platform', { + get: () => 'MacIntel', + enumerable: true, + configurable: true, + }); + } + // ======================================== // 6. CDP ARTIFACT CLEANUP // ======================================== diff --git a/browse/test/stealth-e2e.test.ts b/browse/test/stealth-e2e.test.ts new file mode 100644 index 00000000..653abbeb --- /dev/null +++ b/browse/test/stealth-e2e.test.ts @@ -0,0 +1,238 @@ +/** + * stealth-e2e.test.ts — End-to-end stealth verification + * + * Launches a real Chromium instance with stealth patches applied, + * navigates to a page, and verifies all fingerprint vectors are clean. + * + * Requires: Chromium binary (Playwright's bundled or system) + * Slower than unit tests (~5-10s). Run with: bun test stealth-e2e + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { stealthArgs, applyStealthPatches } from '../src/stealth'; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +beforeAll(async () => { + browser = await chromium.launch({ + headless: true, // headless is fine for fingerprint checks + args: [...stealthArgs, '--no-sandbox'], + }); + context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + viewport: { width: 1920, height: 1080 }, + }); + await applyStealthPatches(context); + page = await context.newPage(); + // Navigate to a blank page to initialize the browser context + await page.goto('about:blank'); +}, 30_000); + +afterAll(async () => { + await context?.close().catch(() => {}); + await browser?.close().catch(() => {}); +}); + +describe('stealth e2e — fingerprint verification', () => { + // ─── Webdriver ──────────────────────────────────────── + + test('navigator.webdriver is undefined', async () => { + const val = await page.evaluate(() => navigator.webdriver); + expect(val).toBeUndefined(); + }); + + test('"webdriver" is not in navigator (property existence check)', async () => { + const exists = await page.evaluate(() => 'webdriver' in navigator); + expect(exists).toBe(false); + }); + + // ─── WebGL ──────────────────────────────────────────── + + test('WebGL vendor is spoofed (not SwiftShader)', async () => { + const vendor = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl'); + if (!gl) return null; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (!ext) return null; + return gl.getParameter(ext.UNMASKED_VENDOR_WEBGL); + }); + expect(vendor).toBeTruthy(); + expect(vendor).toContain('Apple'); + expect(vendor).not.toContain('SwiftShader'); + }); + + test('WebGL renderer is spoofed to Apple M1 Pro', async () => { + const renderer = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl'); + if (!gl) return null; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (!ext) return null; + return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); + }); + expect(renderer).toBeTruthy(); + expect(renderer).toContain('Apple M1 Pro'); + expect(renderer).not.toContain('SwiftShader'); + expect(renderer).not.toContain('llvmpipe'); + }); + + test('WebGL2 renderer is also spoofed', async () => { + const renderer = await page.evaluate(() => { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) return null; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + if (!ext) return null; + return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); + }); + // WebGL2 might not be available in all environments + if (renderer !== null) { + expect(renderer).toContain('Apple M1 Pro'); + } + }); + + // ─── Plugins ────────────────────────────────────────── + + test('navigator.plugins has 5 entries', async () => { + const len = await page.evaluate(() => navigator.plugins.length); + expect(len).toBe(5); + }); + + test('navigator.plugins passes instanceof PluginArray', async () => { + const isPluginArray = await page.evaluate(() => navigator.plugins instanceof PluginArray); + expect(isPluginArray).toBe(true); + }); + + test('navigator.plugins[0] is a Plugin with correct shape', async () => { + const info = await page.evaluate(() => { + const p = navigator.plugins[0]; + return { + name: p?.name, + filename: p?.filename, + hasItem: typeof p?.item === 'function', + hasNamedItem: typeof p?.namedItem === 'function', + }; + }); + expect(info.name).toBe('Chrome PDF Plugin'); + expect(info.filename).toBe('internal-pdf-viewer'); + expect(info.hasItem).toBe(true); + expect(info.hasNamedItem).toBe(true); + }); + + // ─── Chrome Object ──────────────────────────────────── + + test('window.chrome exists and has app', async () => { + const hasApp = await page.evaluate(() => !!(window as any).chrome?.app); + expect(hasApp).toBe(true); + }); + + test('window.chrome.app has correct shape', async () => { + const shape = await page.evaluate(() => { + const app = (window as any).chrome?.app; + return { + hasInstallState: !!app?.InstallState, + hasRunningState: !!app?.RunningState, + getDetails: typeof app?.getDetails, + }; + }); + expect(shape.hasInstallState).toBe(true); + expect(shape.hasRunningState).toBe(true); + expect(shape.getDetails).toBe('function'); + }); + + test('window.chrome.runtime exists', async () => { + const exists = await page.evaluate(() => !!(window as any).chrome?.runtime); + expect(exists).toBe(true); + }); + + test('window.chrome.loadTimes returns object', async () => { + const result = await page.evaluate(() => { + const lt = (window as any).chrome?.loadTimes; + return typeof lt === 'function' ? typeof lt() : 'not a function'; + }); + expect(result).toBe('object'); + }); + + // ─── Languages ──────────────────────────────────────── + + test('navigator.languages is [en-US, en]', async () => { + const langs = await page.evaluate(() => [...navigator.languages]); + expect(langs).toEqual(['en-US', 'en']); + }); + + // ─── Permissions ────────────────────────────────────── + + test('notification permission returns prompt', async () => { + const state = await page.evaluate(async () => { + const result = await navigator.permissions.query({ name: 'notifications' as any }); + return result.state; + }); + expect(state).toBe('prompt'); + }); + + // ─── CDP Artifacts ──────────────────────────────────── + + test('no cdc_ properties on window', async () => { + const cdcKeys = await page.evaluate(() => + Object.keys(window).filter(k => k.startsWith('cdc_') || k.startsWith('$cdc_')) + ); + expect(cdcKeys).toEqual([]); + }); + + test('no __webdriver properties on document', async () => { + const wdKeys = await page.evaluate(() => + Object.keys(document).filter(k => k.startsWith('__webdriver') || k.startsWith('__selenium')) + ); + expect(wdKeys).toEqual([]); + }); + + // ─── Automation Frameworks ──────────────────────────── + + test('no Playwright globals leaked', async () => { + const leaked = await page.evaluate(() => ({ + __playwright: !!(window as any).__playwright, + __pw_manual: !!(window as any).__pw_manual, + _phantom: !!(window as any)._phantom, + __nightmare: !!(window as any).__nightmare, + _selenium: !!(window as any)._selenium, + })); + expect(leaked.__playwright).toBe(false); + expect(leaked.__pw_manual).toBe(false); + expect(leaked._phantom).toBe(false); + expect(leaked.__nightmare).toBe(false); + expect(leaked._selenium).toBe(false); + }); + + // ─── Platform Consistency ───────────────────────────── + + test('navigator.platform matches user agent (MacIntel)', async () => { + const platform = await page.evaluate(() => navigator.platform); + // Our UA says Mac, so platform should be MacIntel + expect(platform).toBe('MacIntel'); + }); + + // ─── Stealth survives navigation ────────────────────── + + test('patches survive page navigation', async () => { + // Navigate to a data: URL (new document load) + await page.goto('data:text/html,

test

'); + + const checks = await page.evaluate(() => ({ + webdriverUndef: navigator.webdriver === undefined, + webdriverNotIn: !('webdriver' in navigator), + pluginsLength: navigator.plugins.length, + hasChrome: !!(window as any).chrome?.app, + langs: [...navigator.languages], + })); + + expect(checks.webdriverUndef).toBe(true); + expect(checks.webdriverNotIn).toBe(true); + expect(checks.pluginsLength).toBe(5); + expect(checks.hasChrome).toBe(true); + expect(checks.langs).toEqual(['en-US', 'en']); + }); +}, 30_000); diff --git a/browse/test/stealth.test.ts b/browse/test/stealth.test.ts new file mode 100644 index 00000000..cbd05df0 --- /dev/null +++ b/browse/test/stealth.test.ts @@ -0,0 +1,323 @@ +/** + * stealth.test.ts — Unit + integration tests for anti-bot stealth patches + * + * Tests: + * 1. Module exports (stealthArgs, applyStealthPatches) + * 2. Launch args correctness + * 3. Init script content validation (parsed from source) + * 4. Integration with BrowserManager (import path, no crash) + * 5. Adversarial: prototype pollution, toString traps, WebGL spoof values + */ + +import { describe, test, expect } from 'bun:test'; +import { stealthArgs, applyStealthPatches } from '../src/stealth'; + +// ─── 1. Module Exports ────────────────────────────────── + +describe('stealth module exports', () => { + test('stealthArgs is a non-empty array of strings', () => { + expect(Array.isArray(stealthArgs)).toBe(true); + expect(stealthArgs.length).toBeGreaterThan(0); + for (const arg of stealthArgs) { + expect(typeof arg).toBe('string'); + expect(arg.startsWith('--')).toBe(true); + } + }); + + test('applyStealthPatches is an async function', () => { + expect(typeof applyStealthPatches).toBe('function'); + // Should accept a context-like object without crashing at import time + }); +}); + +// ─── 2. Launch Args ───────────────────────────────────── + +describe('stealthArgs content', () => { + test('includes AutomationControlled disable', () => { + expect(stealthArgs).toContain('--disable-blink-features=AutomationControlled'); + }); + + test('includes no-first-run to avoid welcome page', () => { + expect(stealthArgs).toContain('--no-first-run'); + }); + + test('does not include --headless (that is a separate concern)', () => { + expect(stealthArgs.some(a => a.includes('headless'))).toBe(false); + }); + + test('does not include --no-sandbox (environment-specific)', () => { + expect(stealthArgs.some(a => a.includes('no-sandbox'))).toBe(false); + }); + + test('does not include proxy args (runtime-specific)', () => { + expect(stealthArgs.some(a => a.includes('proxy'))).toBe(false); + }); +}); + +// ─── 3. Init Script Content (source analysis) ────────── + +describe('init script coverage', () => { + // Read the source to verify all patches are present + const source = require('fs').readFileSync( + require('path').join(__dirname, '../src/stealth.ts'), + 'utf-8', + ); + + test('patches navigator.webdriver via prototype deletion', () => { + expect(source).toContain('Navigator.prototype'); + expect(source).toContain('webdriver'); + expect(source).toContain('delete'); + }); + + test('patches WebGL renderer (both WebGL1 and WebGL2)', () => { + expect(source).toContain('WebGLRenderingContext.prototype.getParameter'); + expect(source).toContain('WebGL2RenderingContext'); + expect(source).toContain('0x9245'); // UNMASKED_VENDOR + expect(source).toContain('0x9246'); // UNMASKED_RENDERER + }); + + test('creates proper PluginArray (not raw array)', () => { + expect(source).toContain('PluginArray.prototype'); + expect(source).toContain('MimeType.prototype'); + expect(source).toContain('Plugin.prototype'); + expect(source).toContain('Symbol.iterator'); + }); + + test('sets up complete chrome object with app', () => { + expect(source).toContain('chrome.app'); + expect(source).toContain('InstallState'); + expect(source).toContain('RunningState'); + expect(source).toContain('chrome.runtime'); + expect(source).toContain('chrome.csi'); + expect(source).toContain('chrome.loadTimes'); + }); + + test('cleans CDP artifacts', () => { + expect(source).toContain('cdc_'); + expect(source).toContain('$cdc_'); + expect(source).toContain('__webdriver'); + expect(source).toContain('__selenium'); + }); + + test('patches Permissions API for notifications', () => { + expect(source).toContain('permissions'); + expect(source).toContain('notifications'); + expect(source).toContain('prompt'); + }); + + test('patches Function.prototype.toString', () => { + expect(source).toContain('Function.prototype.toString'); + expect(source).toContain('[native code]'); + }); + + test('handles mediaDevices for containers', () => { + expect(source).toContain('mediaDevices'); + expect(source).toContain('enumerateDevices'); + expect(source).toContain('getUserMedia'); + }); + + test('spoofs navigator.platform to match UA', () => { + expect(source).toContain('navigator.platform'); + expect(source).toContain('MacIntel'); + expect(source).toContain('Macintosh'); + }); + + test('passes GPU vendor/renderer as args (not hardcoded in browser context)', () => { + // The function signature should accept args for GPU strings + expect(source).toContain('gpuVendor'); + expect(source).toContain('gpuRenderer'); + // And pass them to addInitScript as the second arg + expect(source).toContain('[gpuVendor, gpuRenderer]'); + }); +}); + +// ─── 4. applyStealthPatches API ───────────────────────── + +describe('applyStealthPatches API', () => { + test('rejects when called without a context', async () => { + // @ts-expect-error - intentionally passing null + await expect(applyStealthPatches(null)).rejects.toThrow(); + }); + + test('rejects when context has no addInitScript', async () => { + // @ts-expect-error - intentionally passing incomplete mock + await expect(applyStealthPatches({})).rejects.toThrow(); + }); + + test('calls addInitScript on a mock context', async () => { + let called = false; + let receivedArg: unknown; + const mockContext = { + addInitScript: async (fn: unknown, arg: unknown) => { + called = true; + receivedArg = arg; + }, + }; + // @ts-expect-error - mock + await applyStealthPatches(mockContext); + expect(called).toBe(true); + }); + + test('passes GPU args as [vendor, renderer] tuple', async () => { + let receivedArg: unknown; + const mockContext = { + addInitScript: async (_fn: unknown, arg: unknown) => { + receivedArg = arg; + }, + }; + // @ts-expect-error - mock + await applyStealthPatches(mockContext, { + gpuVendor: 'TestVendor', + gpuRenderer: 'TestRenderer', + }); + expect(receivedArg).toEqual(['TestVendor', 'TestRenderer']); + }); + + test('uses default GPU strings when no options provided', async () => { + let receivedArg: unknown; + const mockContext = { + addInitScript: async (_fn: unknown, arg: unknown) => { + receivedArg = arg; + }, + }; + // @ts-expect-error - mock + await applyStealthPatches(mockContext); + const [vendor, renderer] = receivedArg as [string, string]; + expect(vendor).toContain('Apple'); + expect(renderer).toContain('M1 Pro'); + }); + + test('init script function is serializable (no closures over Node APIs)', async () => { + let capturedFn: Function | null = null; + const mockContext = { + addInitScript: async (fn: unknown, _arg: unknown) => { + capturedFn = fn as Function; + }, + }; + // @ts-expect-error - mock + await applyStealthPatches(mockContext); + expect(capturedFn).not.toBeNull(); + // The function should be serializable via toString (Playwright does this) + const str = capturedFn!.toString(); + expect(str).toContain('Navigator.prototype'); + // Should NOT reference any Node.js APIs (require, process, Buffer, etc.) + expect(str).not.toContain('require('); + expect(str).not.toContain('process.'); + expect(str).not.toContain('Buffer.'); + expect(str).not.toContain('__dirname'); + expect(str).not.toContain('__filename'); + }); +}); + +// ─── 5. Adversarial: Edge Cases ───────────────────────── + +describe('adversarial edge cases', () => { + test('stealthArgs are safe to spread into existing arrays', () => { + const existing = ['--no-sandbox', '--disable-gpu']; + const combined = [...existing, ...stealthArgs]; + expect(combined.length).toBe(existing.length + stealthArgs.length); + // No duplicates of safety-critical flags + const unique = new Set(combined); + expect(unique.size).toBe(combined.length); + }); + + test('stealthArgs do not contain flags that break extension loading', () => { + // These flags would break GStack's headed mode with extension + const forbidden = ['--disable-extensions', '--disable-component-extensions-with-background-pages']; + for (const flag of forbidden) { + expect(stealthArgs).not.toContain(flag); + } + }); + + test('GPU spoof strings are plausible (not detectable as fake)', () => { + // The default GPU strings should match what a real Mac reports + let receivedArg: [string, string] | null = null; + const mockContext = { + addInitScript: async (_fn: unknown, arg: unknown) => { + receivedArg = arg as [string, string]; + }, + }; + // @ts-expect-error - mock + applyStealthPatches(mockContext).then(() => { + const [vendor, renderer] = receivedArg!; + // Should look like a real Apple GPU report + expect(vendor).toMatch(/Google Inc\./); + expect(renderer).toMatch(/ANGLE.*Apple.*M1/); + // Should NOT contain SwiftShader, llvmpipe, or Mesa + expect(renderer).not.toMatch(/SwiftShader|llvmpipe|Mesa|Subzero/i); + }); + }); + + test('applyStealthPatches can be called multiple times without error', async () => { + let callCount = 0; + const mockContext = { + addInitScript: async () => { callCount++; }, + }; + // @ts-expect-error - mock + await applyStealthPatches(mockContext); + // @ts-expect-error - mock + await applyStealthPatches(mockContext); + // Should be called twice (no guard against double-apply) + // This is fine — Playwright deduplicates addInitScript internally + expect(callCount).toBe(2); + }); +}); + +// ─── 6. Import Integration ────────────────────────────── + +describe('import integration', () => { + test('browser-manager.ts imports stealth module', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + expect(bmSource).toContain("import { stealthArgs, applyStealthPatches } from './stealth'"); + }); + + test('browser-manager.ts uses stealthArgs in launch()', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + // In launch() — headless path + expect(bmSource).toContain('...stealthArgs'); + }); + + test('browser-manager.ts calls applyStealthPatches in launch()', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + expect(bmSource).toContain('await applyStealthPatches(this.context)'); + }); + + test('browser-manager.ts uses stealthArgs in launchHeaded()', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + expect(bmSource).toContain('...stealthArgs'); + }); + + test('browser-manager.ts calls applyStealthPatches in launchHeaded()', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + // Should have exactly 2 calls to applyStealthPatches (launch + launchHeaded) + const matches = bmSource.match(/applyStealthPatches/g) || []; + expect(matches.length).toBeGreaterThanOrEqual(3); // import + 2 calls + }); + + test('old inline stealth patches are removed from browser-manager.ts', () => { + const bmSource = require('fs').readFileSync( + require('path').join(__dirname, '../src/browser-manager.ts'), + 'utf-8', + ); + // Old inline patches should be gone + expect(bmSource).not.toContain("name: 'PDF Viewer'"); + expect(bmSource).not.toContain("Fake plugins array"); + expect(bmSource).not.toContain("Fake languages"); + expect(bmSource).not.toContain("key.startsWith('cdc_')"); + }); +});