diff --git a/browser_init.go b/browser_init.go index de4cb77..c66b99a 100644 --- a/browser_init.go +++ b/browser_init.go @@ -92,6 +92,9 @@ func initBrowser(opt BrowserOptions) (*browserInitResult, error) { if len(launchArgs) > 0 { launchOpts.Args = launchArgs } + if stealth && opt.Browser == BrowserChromium && headless { + launchOpts.Channel = playwright.String("chromium") + } browser, err = bt.Launch(launchOpts) if err != nil { return nil, fmt.Errorf("failed to launch browser: %w", err) diff --git a/stealth.go b/stealth.go index b527d9e..a7e9251 100644 --- a/stealth.go +++ b/stealth.go @@ -47,4 +47,88 @@ var stealthInitScripts = []string{ 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) { + const origQuery = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = function(desc) { + if (desc && desc.name === 'notifications') { + return Promise.resolve({ state: 'denied', onchange: null }); + } + return origQuery(desc); + }; + } + })()`, + + // Stub Notification constructor if missing (headless may lack it). + `(function() { + if (typeof Notification === 'undefined') { + window.Notification = function() {}; + Notification.permission = 'denied'; + Notification.requestPermission = function() { return Promise.resolve('denied'); }; + } + })()`, + + // Stub navigator.connection (Network Information API) if missing. + `(function() { + if (!navigator.connection) { + Object.defineProperty(navigator, 'connection', { + get: function() { + return { effectiveType: '4g', rtt: 50, downlink: 10, saveData: false, onchange: null }; + }, + }); + } + })()`, + + // Remove CDP artifacts (window.cdc_* globals injected by Chrome DevTools Protocol). + `(function() { + for (var key in window) { + if (key.match(/^cdc_/)) { + delete window[key]; + } + } + })()`, + + // Strip "HeadlessChrome" from navigator.userAgent if present. + `(function() { + var ua = navigator.userAgent; + if (ua.indexOf('HeadlessChrome') !== -1) { + Object.defineProperty(navigator, 'userAgent', { + get: function() { return ua.replace('HeadlessChrome', 'Chrome'); }, + }); + } + })()`, } diff --git a/stealth_test.go b/stealth_test.go index b547966..33010c9 100644 --- a/stealth_test.go +++ b/stealth_test.go @@ -1,6 +1,7 @@ package extractor import ( + "strings" "testing" ) @@ -70,3 +71,100 @@ func TestStealthInitScripts(t *testing.T) { t.Fatal("expected at least one stealth init script") } } + +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) { + found := false + for _, s := range stealthInitScripts { + if strings.Contains(s, "SwiftShader") || strings.Contains(s, "UNMASKED_RENDERER") || strings.Contains(s, "37446") { + found = true + break + } + } + if !found { + t.Fatal("expected a stealth script that spoofs WebGL renderer") + } +} + +func TestStealthInitScripts_ChromeApp(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") { + found = true + break + } + } + if !found { + t.Fatal("expected a stealth script that stubs chrome.app, chrome.csi, and chrome.loadTimes") + } +} + +func TestStealthInitScripts_PermissionsQuery(t *testing.T) { + found := false + for _, s := range stealthInitScripts { + 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") + } +} + +func TestStealthInitScripts_Notification(t *testing.T) { + found := false + for _, s := range stealthInitScripts { + if strings.Contains(s, "Notification") && strings.Contains(s, "requestPermission") { + found = true + break + } + } + if !found { + t.Fatal("expected a stealth script that stubs Notification constructor") + } +} + +func TestStealthInitScripts_NavigatorConnection(t *testing.T) { + found := false + for _, s := range stealthInitScripts { + if strings.Contains(s, "connection") && strings.Contains(s, "effectiveType") { + found = true + break + } + } + if !found { + t.Fatal("expected a stealth script that stubs navigator.connection") + } +} + +func TestStealthInitScripts_CDPCleanup(t *testing.T) { + found := false + for _, s := range stealthInitScripts { + 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") + } +} + +func TestStealthInitScripts_UserAgentStrip(t *testing.T) { + found := false + for _, s := range stealthInitScripts { + 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") + } +}