use anyhow::{anyhow, Result};
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::Page;
const STEALTH_JS: &str = r#"
(() => {
/* navigator.webdriver — the single biggest tell. */
try {
Object.defineProperty(Navigator.prototype, 'webdriver', {
get: () => undefined,
configurable: true,
});
} catch (_) {}
/* navigator.plugins — empty array means headless. Inject a
plausible PDF plugin set so `plugins.length > 0`. */
try {
const fakePlugin = (name, filename, description) => ({
name, filename, description,
length: 1,
item: () => null,
namedItem: () => null,
});
const plugins = [
fakePlugin('PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
fakePlugin('Chrome PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
fakePlugin('Chromium PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
fakePlugin('Microsoft Edge PDF Viewer', 'internal-pdf-viewer', 'Portable Document Format'),
fakePlugin('WebKit built-in PDF', 'internal-pdf-viewer', 'Portable Document Format'),
];
Object.defineProperty(Navigator.prototype, 'plugins', {
get: () => Object.assign(plugins, { item: i => plugins[i], length: plugins.length }),
configurable: true,
});
} catch (_) {}
/* navigator.languages — headless ships [], real Chrome [en-US, en]. */
try {
Object.defineProperty(Navigator.prototype, 'languages', {
get: () => ['en-US', 'en'],
configurable: true,
});
} catch (_) {}
/* Notification.permission must match reality. Some headless
contexts return 'denied' even when a getNotificationPermission
call returns 'default' — detectors compare both. */
try {
const origQuery = window.navigator.permissions && window.navigator.permissions.query;
if (origQuery) {
window.navigator.permissions.query = (params) => (
params && params.name === 'notifications'
? Promise.resolve({ state: Notification.permission, onchange: null })
: origQuery(params)
);
}
} catch (_) {}
/* window.chrome — headless lacks it; real Chrome has a sizable
object. Inject the bare minimum keys the common detectors
check. */
try {
if (typeof window.chrome === 'undefined') {
window.chrome = {
runtime: {},
loadTimes: function() {},
csi: function() {},
app: {},
};
}
} catch (_) {}
/* WebGL vendor + renderer — headless leaks the GPU surface
(e.g. "ANGLE (NVIDIA, ..., GL_HEADLESS)"). Override to a
plausible Intel integrated card. UNMASKED_VENDOR_WEBGL = 0x9245,
UNMASKED_RENDERER_WEBGL = 0x9246 (debug renderer info ext). */
try {
const getParameterProto = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (parameter) {
if (parameter === 0x9245) return 'Intel Inc.';
if (parameter === 0x9246) return 'Intel Iris OpenGL Engine';
return getParameterProto.apply(this, [parameter]);
};
if (typeof WebGL2RenderingContext !== 'undefined') {
const getParameterProto2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function (parameter) {
if (parameter === 0x9245) return 'Intel Inc.';
if (parameter === 0x9246) return 'Intel Iris OpenGL Engine';
return getParameterProto2.apply(this, [parameter]);
};
}
} catch (_) {}
/* iframe contentWindow — some detectors check that an injected
iframe's `contentWindow.chrome` matches the parent's. Patch
HTMLIFrameElement.prototype.contentWindow accessor to forward
chrome. */
try {
const origContentWindow = Object.getOwnPropertyDescriptor(
HTMLIFrameElement.prototype, 'contentWindow'
);
if (origContentWindow && origContentWindow.get) {
const origGet = origContentWindow.get;
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
get: function () {
const w = origGet.apply(this);
if (w && typeof w.chrome === 'undefined') {
try { w.chrome = window.chrome; } catch (_) {}
}
return w;
},
configurable: true,
});
}
} catch (_) {}
})();
"#;
pub async fn apply_stealth(page: &Page) -> Result<()> {
page.execute(AddScriptToEvaluateOnNewDocumentParams {
source: STEALTH_JS.to_string(),
world_name: None,
include_command_line_api: None,
run_immediately: Some(true),
})
.await
.map_err(|e| anyhow!("stealth: addScriptToEvaluateOnNewDocument failed: {e}"))?;
Ok(())
}
#[doc(hidden)]
pub fn stealth_js_source() -> &'static str {
STEALTH_JS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stealth_js_overrides_documented_surfaces() {
let must_contain = [
"navigator", "webdriver",
"plugins",
"languages",
"chrome",
"WebGLRenderingContext",
"Notification.permission",
];
for needle in must_contain {
assert!(
STEALTH_JS.contains(needle),
"stealth JS must override {needle}"
);
}
}
#[test]
fn stealth_js_uses_defineproperty_on_prototype() {
assert!(STEALTH_JS.contains("Navigator.prototype"));
}
#[test]
fn stealth_js_handles_webgl2_separately() {
assert!(STEALTH_JS.contains("WebGL2RenderingContext"));
}
#[test]
fn stealth_js_uses_iife_to_avoid_leaking_globals() {
let trimmed = STEALTH_JS.trim();
assert!(
trimmed.starts_with("(() =>") && trimmed.ends_with(")();"),
"stealth JS must be wrapped in an IIFE"
);
}
#[test]
fn stealth_js_swallows_errors_per_block() {
let try_count = STEALTH_JS.matches("try {").count();
let catch_count = STEALTH_JS.matches("catch (_)").count();
assert!(
try_count >= 6 && catch_count >= 6,
"expected at least 6 try/catch blocks for resilience; \
got try={try_count} catch={catch_count}"
);
}
#[test]
fn stealth_js_source_returns_constant() {
assert_eq!(stealth_js_source(), STEALTH_JS);
}
}