diff --git a/browse/src/stealth.ts b/browse/src/stealth.ts index 9ad31ab7..9c57e3f0 100644 --- a/browse/src/stealth.ts +++ b/browse/src/stealth.ts @@ -53,8 +53,17 @@ export async function applyStealthPatches( gpuVendor?: string; }, ): Promise { + // Default GPU strings match common real-world Mac hardware. + // Vary slightly across sessions to avoid creating a static fingerprint. + const gpuVariants = [ + 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)', + 'ANGLE (Apple, Apple M2, OpenGL 4.1)', + 'ANGLE (Apple, Apple M1, OpenGL 4.1)', + 'ANGLE (Apple, Apple M3, OpenGL 4.1)', + 'ANGLE (Apple, Apple M1 Max, OpenGL 4.1)', + ]; const gpuVendor = options?.gpuVendor ?? 'Google Inc. (Apple)'; - const gpuRenderer = options?.gpuRenderer ?? 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)'; + const gpuRenderer = options?.gpuRenderer ?? gpuVariants[Math.floor(Math.random() * gpuVariants.length)]; await context.addInitScript( ([vendor, renderer]: [string, string]) => { @@ -259,11 +268,18 @@ export async function applyStealthPatches( // 9. FUNCTION toString PROTECTION // ======================================== // Make overridden functions look native to .toString() checks. + // SECURITY: Use a WeakMap with a frozen lookup to prevent malicious pages + // from exfiltrating the map via Map.prototype.has/get monkeypatching. + // WeakMap doesn't iterate and can't be fully leaked via prototype hooks. const nativeStr = Function.prototype.toString; - const overrides = new Map(); + const overrides = new WeakMap(); + // Freeze a reference to the original WeakMap methods before any page + // script can monkeypatch them. + const wmHas = WeakMap.prototype.has.bind(overrides); + const wmGet = WeakMap.prototype.get.bind(overrides); Function.prototype.toString = function () { - if (overrides.has(this)) return overrides.get(this)!; + if (wmHas(this)) return wmGet(this)!; return nativeStr.call(this); }; overrides.set(Function.prototype.toString, 'function toString() { [native code] }'); diff --git a/browse/test/stealth-e2e.test.ts b/browse/test/stealth-e2e.test.ts index 653abbeb..feebd49f 100644 --- a/browse/test/stealth-e2e.test.ts +++ b/browse/test/stealth-e2e.test.ts @@ -65,7 +65,7 @@ describe('stealth e2e — fingerprint verification', () => { expect(vendor).not.toContain('SwiftShader'); }); - test('WebGL renderer is spoofed to Apple M1 Pro', async () => { + test('WebGL renderer is spoofed to an Apple chip', async () => { const renderer = await page.evaluate(() => { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl'); @@ -75,7 +75,7 @@ describe('stealth e2e — fingerprint verification', () => { return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL); }); expect(renderer).toBeTruthy(); - expect(renderer).toContain('Apple M1 Pro'); + expect(renderer).toMatch(/Apple.*M[123]/); expect(renderer).not.toContain('SwiftShader'); expect(renderer).not.toContain('llvmpipe'); }); @@ -91,7 +91,7 @@ describe('stealth e2e — fingerprint verification', () => { }); // WebGL2 might not be available in all environments if (renderer !== null) { - expect(renderer).toContain('Apple M1 Pro'); + expect(renderer).toMatch(/Apple.*M[123]/); } }); diff --git a/browse/test/stealth.test.ts b/browse/test/stealth.test.ts index cbd05df0..f0a0a3ee 100644 --- a/browse/test/stealth.test.ts +++ b/browse/test/stealth.test.ts @@ -110,6 +110,21 @@ describe('init script coverage', () => { expect(source).toContain('[native code]'); }); + test('uses WeakMap (not Map) for toString overrides to prevent exfiltration', () => { + // Security: Map can be exfiltrated via Map.prototype.has monkeypatching. + // WeakMap with bound methods prevents this attack vector. + expect(source).toContain('new WeakMap'); + expect(source).toContain('WeakMap.prototype.has.bind'); + expect(source).toContain('WeakMap.prototype.get.bind'); + // Must NOT use plain Map for the override store + expect(source).not.toMatch(/new Map[<(]/); + }); + + test('GPU renderer varies across sessions (anti-fingerprint)', () => { + expect(source).toContain('gpuVariants'); + expect(source).toContain('Math.random'); + }); + test('handles mediaDevices for containers', () => { expect(source).toContain('mediaDevices'); expect(source).toContain('enumerateDevices'); @@ -184,7 +199,8 @@ describe('applyStealthPatches API', () => { await applyStealthPatches(mockContext); const [vendor, renderer] = receivedArg as [string, string]; expect(vendor).toContain('Apple'); - expect(renderer).toContain('M1 Pro'); + // Renderer varies across sessions but should always be an Apple chip + expect(renderer).toMatch(/Apple.*M[123]/); }); test('init script function is serializable (no closures over Node APIs)', async () => {