From 34161209de7cdda4bc4bee7c74b0ca54d6ddd67f Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 24 Feb 2026 01:20:40 +0000 Subject: [PATCH] fix: split stealth init scripts by browser engine and add Firefox stealth The stealth system previously injected all 12 init scripts unconditionally into every browser engine. Chromium-specific scripts (window.chrome stubs, ANGLE WebGL strings, CDP cleanup, HeadlessChrome UA strip) were no-ops or actively suspicious on Firefox, while Firefox-specific headless vectors were unaddressed. Split stealthInitScripts into three categories: - stealthCommonScripts (4): webdriver, outerWidth/Height, permissions, Notification - stealthChromiumScripts (8): existing Chromium-specific scripts - stealthFirefoxScripts (5): new Firefox-specific stealth: - navigator.webdriver getOwnPropertyDescriptor hardening - WebGL renderer spoof with Mesa/Intel strings - mozInnerScreenX/Y non-zero spoof - navigator.hardwareConcurrency normalization - PDF.js plugin list override browser_init.go now selects common + engine-specific scripts based on opt.Browser. Tests updated with per-category validation and cross- contamination checks. Closes #69 Co-Authored-By: Claude Opus 4.6 --- browser_init.go | 8 +- stealth.go | 200 +++++++++++++++++++++++------------ stealth_test.go | 270 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 378 insertions(+), 100 deletions(-) diff --git a/browser_init.go b/browser_init.go index c66b99a..e5e8c62 100644 --- a/browser_init.go +++ b/browser_init.go @@ -61,7 +61,13 @@ func initBrowser(opt BrowserOptions) (*browserInitResult, error) { if opt.Browser == BrowserChromium { launchArgs = append(launchArgs, stealthChromiumArgs...) } - initScripts = append(initScripts, stealthInitScripts...) + initScripts = append(initScripts, stealthCommonScripts...) + switch opt.Browser { + case BrowserChromium: + initScripts = append(initScripts, stealthChromiumScripts...) + case BrowserFirefox: + initScripts = append(initScripts, stealthFirefoxScripts...) + } } launchArgs = append(launchArgs, opt.LaunchArgs...) diff --git a/stealth.go b/stealth.go index a7e9251..adc2b74 100644 --- a/stealth.go +++ b/stealth.go @@ -5,81 +5,17 @@ var stealthChromiumArgs = []string{ "--disable-blink-features=AutomationControlled", } -// stealthInitScripts are JavaScript snippets injected before page scripts to mask automation signals. -var stealthInitScripts = []string{ +// stealthCommonScripts are JavaScript snippets injected before page scripts on all browser engines. +var stealthCommonScripts = []string{ // Override navigator.webdriver to return undefined (the real-browser value). `Object.defineProperty(navigator, 'webdriver', {get: () => undefined})`, - // Populate navigator.plugins with a realistic entry so plugins.length > 0. - `Object.defineProperty(navigator, 'plugins', { - get: () => { - const arr = [ - { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: '' }, - { name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' }, - ]; - arr.item = (i) => arr[i] || null; - arr.namedItem = (n) => arr.find(p => p.name === n) || null; - arr.refresh = () => {}; - return arr; - }, - })`, - - // Populate navigator.mimeTypes to match the fake plugins above. - `Object.defineProperty(navigator, 'mimeTypes', { - get: () => { - const arr = [ - { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, - ]; - arr.item = (i) => arr[i] || null; - arr.namedItem = (n) => arr.find(m => m.type === n) || null; - return arr; - }, - })`, - - // Provide window.chrome runtime stub (Chromium-only signal; harmless on other engines). - `if (!window.chrome) { - window.chrome = { runtime: {} }; - }`, - // Fix outerWidth/outerHeight which are 0 in headless mode. `if (window.outerWidth === 0) { Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth }); Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight }); }`, - // Spoof WebGL renderer to hide SwiftShader (headless GPU) fingerprint. - `(function() { - const getParam = WebGLRenderingContext.prototype.getParameter; - WebGLRenderingContext.prototype.getParameter = function(param) { - if (param === 37445) return 'Google Inc. (Intel)'; - if (param === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)'; - return getParam.call(this, param); - }; - if (typeof WebGL2RenderingContext !== 'undefined') { - const getParam2 = WebGL2RenderingContext.prototype.getParameter; - WebGL2RenderingContext.prototype.getParameter = function(param) { - if (param === 37445) return 'Google Inc. (Intel)'; - if (param === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)'; - return getParam2.call(this, param); - }; - } - })()`, - - // Add chrome.app, chrome.csi, and chrome.loadTimes stubs missing in headless. - `(function() { - if (!window.chrome) window.chrome = {}; - if (!window.chrome.app) { - window.chrome.app = { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } }; - } - if (!window.chrome.csi) { - window.chrome.csi = function() { return { startE: Date.now(), onloadT: Date.now(), pageT: 0, tran: 15 }; }; - } - if (!window.chrome.loadTimes) { - window.chrome.loadTimes = function() { return { commitLoadTime: Date.now() / 1000, connectionInfo: 'h2', finishDocumentLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: Date.now() / 1000, startLoadTime: Date.now() / 1000, wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true }; }; - } - })()`, - // Override navigator.permissions.query to return "denied" for notifications. `(function() { if (navigator.permissions && navigator.permissions.query) { @@ -101,8 +37,75 @@ var stealthInitScripts = []string{ Notification.requestPermission = function() { return Promise.resolve('denied'); }; } })()`, +} - // Stub navigator.connection (Network Information API) if missing. +// stealthChromiumScripts are JavaScript snippets specific to Chromium-based browsers. +var stealthChromiumScripts = []string{ + // Populate navigator.plugins with realistic Chromium entries so plugins.length > 0. + `Object.defineProperty(navigator, 'plugins', { + get: () => { + const arr = [ + { name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: '' }, + { name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' }, + ]; + arr.item = (i) => arr[i] || null; + arr.namedItem = (n) => arr.find(p => p.name === n) || null; + arr.refresh = () => {}; + return arr; + }, + })`, + + // Populate navigator.mimeTypes to match the fake Chromium plugins above. + `Object.defineProperty(navigator, 'mimeTypes', { + get: () => { + const arr = [ + { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, + ]; + arr.item = (i) => arr[i] || null; + arr.namedItem = (n) => arr.find(m => m.type === n) || null; + return arr; + }, + })`, + + // Provide window.chrome runtime stub (Chromium-only signal). + `if (!window.chrome) { + window.chrome = { runtime: {} }; + }`, + + // Add chrome.app, chrome.csi, and chrome.loadTimes stubs missing in headless. + `(function() { + if (!window.chrome) window.chrome = {}; + if (!window.chrome.app) { + window.chrome.app = { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } }; + } + if (!window.chrome.csi) { + window.chrome.csi = function() { return { startE: Date.now(), onloadT: Date.now(), pageT: 0, tran: 15 }; }; + } + if (!window.chrome.loadTimes) { + window.chrome.loadTimes = function() { return { commitLoadTime: Date.now() / 1000, connectionInfo: 'h2', finishDocumentLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: Date.now() / 1000, startLoadTime: Date.now() / 1000, wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true }; }; + } + })()`, + + // Spoof WebGL renderer to hide SwiftShader (headless GPU) fingerprint with Chromium ANGLE strings. + `(function() { + const getParam = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function(param) { + if (param === 37445) return 'Google Inc. (Intel)'; + if (param === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)'; + return getParam.call(this, param); + }; + if (typeof WebGL2RenderingContext !== 'undefined') { + const getParam2 = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function(param) { + if (param === 37445) return 'Google Inc. (Intel)'; + if (param === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630, OpenGL 4.5)'; + return getParam2.call(this, param); + }; + } + })()`, + + // Stub navigator.connection (Network Information API) if missing (Chrome-only API). `(function() { if (!navigator.connection) { Object.defineProperty(navigator, 'connection', { @@ -132,3 +135,66 @@ var stealthInitScripts = []string{ } })()`, } + +// stealthFirefoxScripts are JavaScript snippets specific to Firefox. +var stealthFirefoxScripts = []string{ + // Harden navigator.webdriver for Firefox: ensure Object.getOwnPropertyDescriptor also returns undefined. + `(function() { + const proto = Object.getPrototypeOf(navigator); + const origGetOwnPropDesc = Object.getOwnPropertyDescriptor; + Object.getOwnPropertyDescriptor = function(obj, prop) { + if ((obj === navigator || obj === proto) && prop === 'webdriver') { + return undefined; + } + return origGetOwnPropDesc.call(this, obj, prop); + }; + })()`, + + // Spoof WebGL renderer with Firefox-appropriate Mesa/Intel strings. + `(function() { + const getParam = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function(param) { + if (param === 37445) return 'Intel Open Source Technology Center'; + if (param === 37446) return 'Mesa DRI Intel(R) UHD Graphics 630'; + return getParam.call(this, param); + }; + if (typeof WebGL2RenderingContext !== 'undefined') { + const getParam2 = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function(param) { + if (param === 37445) return 'Intel Open Source Technology Center'; + if (param === 37446) return 'Mesa DRI Intel(R) UHD Graphics 630'; + return getParam2.call(this, param); + }; + } + })()`, + + // Spoof mozInnerScreenX/mozInnerScreenY which are 0 in headless Firefox. + `(function() { + if (window.mozInnerScreenX === 0) { + Object.defineProperty(window, 'mozInnerScreenX', { get: () => 8 }); + } + if (window.mozInnerScreenY === 0) { + Object.defineProperty(window, 'mozInnerScreenY', { get: () => 51 }); + } + })()`, + + // Normalize navigator.hardwareConcurrency (Firefox headless sometimes reports 2). + `(function() { + if (navigator.hardwareConcurrency <= 2) { + Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 4 }); + } + })()`, + + // Override navigator.plugins with Firefox-appropriate PDF.js entry. + `Object.defineProperty(navigator, 'plugins', { + get: () => { + const arr = [ + { name: 'PDF.js', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + ]; + arr.item = (i) => arr[i] || null; + arr.namedItem = (n) => arr.find(p => p.name === n) || null; + arr.refresh = () => {}; + return arr; + }, + })`, +} diff --git a/stealth_test.go b/stealth_test.go index 33010c9..5c8f997 100644 --- a/stealth_test.go +++ b/stealth_test.go @@ -66,105 +66,311 @@ func TestStealthChromiumArgs(t *testing.T) { } } -func TestStealthInitScripts(t *testing.T) { - if len(stealthInitScripts) == 0 { - t.Fatal("expected at least one stealth init script") +// --- Common scripts --- + +func TestStealthCommonScripts_Count(t *testing.T) { + if len(stealthCommonScripts) != 4 { + t.Fatalf("expected 4 common stealth scripts, got %d", len(stealthCommonScripts)) } } -func TestStealthInitScripts_Count(t *testing.T) { - if len(stealthInitScripts) != 12 { - t.Fatalf("expected 12 stealth init scripts, got %d", len(stealthInitScripts)) - } -} - -func TestStealthInitScripts_WebGLSpoof(t *testing.T) { +func TestStealthCommonScripts_WebdriverOverride(t *testing.T) { found := false - for _, s := range stealthInitScripts { - if strings.Contains(s, "SwiftShader") || strings.Contains(s, "UNMASKED_RENDERER") || strings.Contains(s, "37446") { + for _, s := range stealthCommonScripts { + if strings.Contains(s, "navigator") && strings.Contains(s, "webdriver") { found = true break } } if !found { - t.Fatal("expected a stealth script that spoofs WebGL renderer") + t.Fatal("expected a common script that overrides navigator.webdriver") } } -func TestStealthInitScripts_ChromeApp(t *testing.T) { +func TestStealthCommonScripts_OuterDimensions(t *testing.T) { found := false - for _, s := range stealthInitScripts { - if strings.Contains(s, "chrome.app") && strings.Contains(s, "chrome.csi") && strings.Contains(s, "chrome.loadTimes") { + for _, s := range stealthCommonScripts { + if strings.Contains(s, "outerWidth") && strings.Contains(s, "outerHeight") { found = true break } } if !found { - t.Fatal("expected a stealth script that stubs chrome.app, chrome.csi, and chrome.loadTimes") + t.Fatal("expected a common script that fixes outerWidth/outerHeight") } } -func TestStealthInitScripts_PermissionsQuery(t *testing.T) { +func TestStealthCommonScripts_PermissionsQuery(t *testing.T) { found := false - for _, s := range stealthInitScripts { + for _, s := range stealthCommonScripts { if strings.Contains(s, "permissions.query") && strings.Contains(s, "notifications") { found = true break } } if !found { - t.Fatal("expected a stealth script that overrides permissions.query for notifications") + t.Fatal("expected a common script that overrides permissions.query for notifications") } } -func TestStealthInitScripts_Notification(t *testing.T) { +func TestStealthCommonScripts_Notification(t *testing.T) { found := false - for _, s := range stealthInitScripts { + for _, s := range stealthCommonScripts { if strings.Contains(s, "Notification") && strings.Contains(s, "requestPermission") { found = true break } } if !found { - t.Fatal("expected a stealth script that stubs Notification constructor") + t.Fatal("expected a common script that stubs Notification constructor") } } -func TestStealthInitScripts_NavigatorConnection(t *testing.T) { +// --- Chromium scripts --- + +func TestStealthChromiumScripts_Count(t *testing.T) { + if len(stealthChromiumScripts) != 8 { + t.Fatalf("expected 8 chromium stealth scripts, got %d", len(stealthChromiumScripts)) + } +} + +func TestStealthChromiumScripts_Plugins(t *testing.T) { found := false - for _, s := range stealthInitScripts { + for _, s := range stealthChromiumScripts { + if strings.Contains(s, "Chrome PDF Plugin") && strings.Contains(s, "navigator") && strings.Contains(s, "plugins") { + found = true + break + } + } + if !found { + t.Fatal("expected a chromium script that populates navigator.plugins with Chrome entries") + } +} + +func TestStealthChromiumScripts_MimeTypes(t *testing.T) { + found := false + for _, s := range stealthChromiumScripts { + if strings.Contains(s, "mimeTypes") && strings.Contains(s, "application/pdf") { + found = true + break + } + } + if !found { + t.Fatal("expected a chromium script that populates navigator.mimeTypes") + } +} + +func TestStealthChromiumScripts_WindowChrome(t *testing.T) { + found := false + for _, s := range stealthChromiumScripts { + if strings.Contains(s, "window.chrome") && strings.Contains(s, "runtime") { + found = true + break + } + } + if !found { + t.Fatal("expected a chromium script that stubs window.chrome") + } +} + +func TestStealthChromiumScripts_ChromeApp(t *testing.T) { + found := false + for _, s := range stealthChromiumScripts { + if strings.Contains(s, "chrome.app") && strings.Contains(s, "chrome.csi") && strings.Contains(s, "chrome.loadTimes") { + found = true + break + } + } + if !found { + t.Fatal("expected a chromium script that stubs chrome.app, chrome.csi, and chrome.loadTimes") + } +} + +func TestStealthChromiumScripts_WebGLSpoof(t *testing.T) { + found := false + for _, s := range stealthChromiumScripts { + if strings.Contains(s, "37446") && strings.Contains(s, "ANGLE") { + found = true + break + } + } + if !found { + t.Fatal("expected a chromium script that spoofs WebGL renderer with ANGLE strings") + } +} + +func TestStealthChromiumScripts_NavigatorConnection(t *testing.T) { + found := false + for _, s := range stealthChromiumScripts { if strings.Contains(s, "connection") && strings.Contains(s, "effectiveType") { found = true break } } if !found { - t.Fatal("expected a stealth script that stubs navigator.connection") + t.Fatal("expected a chromium script that stubs navigator.connection") } } -func TestStealthInitScripts_CDPCleanup(t *testing.T) { +func TestStealthChromiumScripts_CDPCleanup(t *testing.T) { found := false - for _, s := range stealthInitScripts { + for _, s := range stealthChromiumScripts { if strings.Contains(s, "cdc_") && strings.Contains(s, "delete") { found = true break } } if !found { - t.Fatal("expected a stealth script that cleans up CDP artifacts") + t.Fatal("expected a chromium script that cleans up CDP artifacts") } } -func TestStealthInitScripts_UserAgentStrip(t *testing.T) { +func TestStealthChromiumScripts_UserAgentStrip(t *testing.T) { found := false - for _, s := range stealthInitScripts { + for _, s := range stealthChromiumScripts { if strings.Contains(s, "HeadlessChrome") && strings.Contains(s, "userAgent") { found = true break } } if !found { - t.Fatal("expected a stealth script that strips HeadlessChrome from user agent") + t.Fatal("expected a chromium script that strips HeadlessChrome from user agent") + } +} + +// --- Firefox scripts --- + +func TestStealthFirefoxScripts_Count(t *testing.T) { + if len(stealthFirefoxScripts) != 5 { + t.Fatalf("expected 5 firefox stealth scripts, got %d", len(stealthFirefoxScripts)) + } +} + +func TestStealthFirefoxScripts_WebdriverHardening(t *testing.T) { + found := false + for _, s := range stealthFirefoxScripts { + if strings.Contains(s, "getOwnPropertyDescriptor") && strings.Contains(s, "webdriver") { + found = true + break + } + } + if !found { + t.Fatal("expected a firefox script that hardens navigator.webdriver via getOwnPropertyDescriptor") + } +} + +func TestStealthFirefoxScripts_WebGLSpoof(t *testing.T) { + found := false + for _, s := range stealthFirefoxScripts { + if strings.Contains(s, "37446") && strings.Contains(s, "Mesa DRI") { + found = true + break + } + } + if !found { + t.Fatal("expected a firefox script that spoofs WebGL renderer with Mesa strings") + } +} + +func TestStealthFirefoxScripts_MozInnerScreen(t *testing.T) { + found := false + for _, s := range stealthFirefoxScripts { + if strings.Contains(s, "mozInnerScreenX") && strings.Contains(s, "mozInnerScreenY") { + found = true + break + } + } + if !found { + t.Fatal("expected a firefox script that spoofs mozInnerScreenX/mozInnerScreenY") + } +} + +func TestStealthFirefoxScripts_HardwareConcurrency(t *testing.T) { + found := false + for _, s := range stealthFirefoxScripts { + if strings.Contains(s, "hardwareConcurrency") { + found = true + break + } + } + if !found { + t.Fatal("expected a firefox script that normalizes navigator.hardwareConcurrency") + } +} + +func TestStealthFirefoxScripts_PDFjsPlugins(t *testing.T) { + found := false + for _, s := range stealthFirefoxScripts { + if strings.Contains(s, "PDF.js") && strings.Contains(s, "plugins") { + found = true + break + } + } + if !found { + t.Fatal("expected a firefox script that provides PDF.js plugin entry") + } +} + +// --- Cross-category validation --- + +func TestStealthScripts_NoOverlap(t *testing.T) { + all := make(map[string]string) // script -> category + for _, s := range stealthCommonScripts { + all[s] = "common" + } + for _, s := range stealthChromiumScripts { + if cat, ok := all[s]; ok { + t.Fatalf("chromium script also appears in %s category", cat) + } + all[s] = "chromium" + } + for _, s := range stealthFirefoxScripts { + if cat, ok := all[s]; ok { + t.Fatalf("firefox script also appears in %s category", cat) + } + } +} + +func TestStealthCommonScripts_NoChromiumMarkers(t *testing.T) { + chromiumMarkers := []string{"window.chrome", "chrome.app", "chrome.csi", "chrome.loadTimes", "HeadlessChrome", "cdc_", "Chrome PDF Plugin", "ANGLE"} + for _, s := range stealthCommonScripts { + for _, marker := range chromiumMarkers { + if strings.Contains(s, marker) { + t.Fatalf("common script contains Chromium-specific marker %q", marker) + } + } + } +} + +func TestStealthCommonScripts_NoFirefoxMarkers(t *testing.T) { + firefoxMarkers := []string{"mozInnerScreen", "Mesa DRI", "PDF.js"} + for _, s := range stealthCommonScripts { + for _, marker := range firefoxMarkers { + if strings.Contains(s, marker) { + t.Fatalf("common script contains Firefox-specific marker %q", marker) + } + } + } +} + +func TestStealthChromiumScripts_NoFirefoxMarkers(t *testing.T) { + firefoxMarkers := []string{"mozInnerScreen", "Mesa DRI", "PDF.js"} + for _, s := range stealthChromiumScripts { + for _, marker := range firefoxMarkers { + if strings.Contains(s, marker) { + t.Fatalf("chromium script contains Firefox-specific marker %q", marker) + } + } + } +} + +func TestStealthFirefoxScripts_NoChromiumMarkers(t *testing.T) { + chromiumMarkers := []string{"window.chrome", "chrome.app", "chrome.csi", "chrome.loadTimes", "HeadlessChrome", "cdc_", "Chrome PDF Plugin", "ANGLE"} + for _, s := range stealthFirefoxScripts { + for _, marker := range chromiumMarkers { + if strings.Contains(s, marker) { + t.Fatalf("firefox script contains Chromium-specific marker %q", marker) + } + } } }