Files
gstack/browse/test/stealth.test.ts
T
gstack 1eae837260 fix(stealth): address security review findings
1. HIGH — Function.toString Map exfiltration:
   Replaced Map with WeakMap + bound methods. A malicious page could
   monkeypatch Map.prototype.has to capture the override store, then
   use it to cloak malicious functions as [native code]. WeakMap with
   pre-bound has/get methods prevents this side-channel.

2. MEDIUM — Static GPU fingerprint:
   Default GPU renderer now randomly selects from 5 common Apple chip
   variants (M1, M1 Pro, M1 Max, M2, M3) per session. Prevents sites
   from building a static GStack-specific fingerprint signature.

3. Tests updated: 54 total (35 unit + 19 e2e), 0 failures.
   Added tests for WeakMap usage and GPU randomization.
2026-04-21 03:08:06 +00:00

340 lines
12 KiB
TypeScript

/**
* 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('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');
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');
// 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 () => {
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_')");
});
});