mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 06:26:45 +02:00
1eae837260
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.
340 lines
12 KiB
TypeScript
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_')");
|
|
});
|
|
});
|