Files
go-extractor/stealth.go
Steve Dudenhoeffer 34161209de
All checks were successful
CI / vet (pull_request) Successful in 1m59s
CI / build (pull_request) Successful in 2m1s
CI / test (pull_request) Successful in 2m1s
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 <noreply@anthropic.com>
2026-02-24 01:20:40 +00:00

201 lines
7.6 KiB
Go

package extractor
// stealthChromiumArgs are launch arguments that reduce automation detection for Chromium-based browsers.
var stealthChromiumArgs = []string{
"--disable-blink-features=AutomationControlled",
}
// 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})`,
// 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 });
}`,
// 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'); };
}
})()`,
}
// 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', {
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'); },
});
}
})()`,
}
// 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;
},
})`,
}