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)" or
"Google Inc. (SwiftShader)"). Override to a plausible Intel
integrated card. The original shim only covered the
UNMASKED_*_WEBGL debug-extension constants (0x9245 / 0x9246),
which detectors had migrated AWAY from years ago — modern
fingerprint suites (incl. Cloudflare Turnstile) now read the
canonical WebGL constants directly:
VENDOR = 0x1F00
RENDERER = 0x1F01
VERSION = 0x1F02
SHADING_LANGUAGE_VERSION= 0x8B8C
Without overrides for those, our earlier Intel shim was a
no-op against current WAFs and the page reported
SwiftShader/swiftshader-webgl regardless. Cover both the
canonical and the unmasked variants so vendor + renderer match
across either lookup path. */
try {
const PATCHED = {
0x1F00: 'Google Inc. (Intel)',
0x1F01: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
0x1F02: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)',
0x8B8C: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)',
0x9245: 'Google Inc. (Intel)',
0x9246: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
};
const PATCHED2 = {
0x1F00: 'Google Inc. (Intel)',
0x1F01: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
0x1F02: 'WebGL 2.0 (OpenGL ES 3.0 Chromium)',
0x8B8C: 'WebGL GLSL ES 3.00 (OpenGL ES GLSL ES 3.0 Chromium)',
0x9245: 'Google Inc. (Intel)',
0x9246: 'ANGLE (Intel, Intel(R) UHD Graphics 620 Direct3D11 vs_5_0 ps_5_0, D3D11)',
};
const getParameterProto = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (parameter) {
if (PATCHED.hasOwnProperty(parameter)) return PATCHED[parameter];
return getParameterProto.apply(this, [parameter]);
};
if (typeof WebGL2RenderingContext !== 'undefined') {
const getParameterProto2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function (parameter) {
if (PATCHED2.hasOwnProperty(parameter)) return PATCHED2[parameter];
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 (_) {}
/* Canvas fingerprint noise — WAFs hash canvas.toDataURL() to
fingerprint the GPU + font stack. Inject 1-bit per-pixel noise
in the alpha channel that's invisible visually but breaks the
hash. Done by patching CanvasRenderingContext2D.getImageData
and HTMLCanvasElement.toDataURL / toBlob. */
try {
const noisify = (data) => {
for (let i = 0; i < data.length; i += 4) {
/* Toggle the lowest bit of the alpha channel
pseudo-randomly — preserves image visually but
changes the hash. */
data[i + 3] = data[i + 3] ^ ((Math.random() < 0.5) ? 0 : 1);
}
};
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function (...args) {
const img = origGetImageData.apply(this, args);
try { noisify(img.data); } catch (_) {}
return img;
};
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (...args) {
try {
const ctx = this.getContext('2d');
if (ctx) {
const img = ctx.getImageData(0, 0, this.width, this.height);
noisify(img.data);
ctx.putImageData(img, 0, 0);
}
} catch (_) {}
return origToDataURL.apply(this, args);
};
} catch (_) {}
/* AudioContext fingerprint noise — hCaptcha, Cloudflare, and
Akamai use AudioContext.createOscillator + DynamicsCompressor +
AnalyserNode.getFloatFrequencyData() to fingerprint audio
hardware. Inject tiny float noise into getFloatFrequencyData /
getChannelData so the resulting hash isn't deterministic. */
try {
const proto = AudioBuffer.prototype;
const origGetChannelData = proto.getChannelData;
proto.getChannelData = function (channel) {
const arr = origGetChannelData.apply(this, [channel]);
try {
/* Add ±1e-7 noise per sample. Inaudible but hash-
breaking. */
for (let i = 0; i < arr.length; i++) {
arr[i] = arr[i] + (Math.random() - 0.5) * 1e-7;
}
} catch (_) {}
return arr;
};
} catch (_) {}
/* WebRTC IP leak prevention — RTCPeerConnection.createOffer
can reveal the host's real IP via mDNS / STUN. Stub the
constructor so any attempt to create an offer either no-ops
or returns a public IP. We don't want to break legitimate
WebRTC usage on the page; just neuter the IP-leak path. */
try {
if (typeof RTCPeerConnection !== 'undefined') {
const origRTCPC = window.RTCPeerConnection;
const wrap = function (...args) {
const pc = new origRTCPC(...args);
/* Override the SDP shaper so any STUN candidate
reflects an obviously-public IP rather than the
real local one. */
const origSetLocal = pc.setLocalDescription.bind(pc);
pc.setLocalDescription = function (desc, ...rest) {
if (desc && typeof desc.sdp === 'string') {
desc.sdp = desc.sdp.replace(
/\b(\d{1,3}\.){3}\d{1,3}\b/g,
'8.8.8.8'
);
}
return origSetLocal(desc, ...rest);
};
return pc;
};
wrap.prototype = origRTCPC.prototype;
window.RTCPeerConnection = wrap;
}
} catch (_) {}
/* navigator.hardwareConcurrency — headless typically reports
the host's full thread count (e.g. 32 on a workstation).
Real browser sessions on consumer hardware are 4-8. Pin to
8 unless the host is under that. */
try {
const real = navigator.hardwareConcurrency || 8;
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {
get: () => Math.min(real, 8),
configurable: true,
});
} catch (_) {}
/* navigator.deviceMemory — same story; many headless setups
leak server-grade RAM. Pin to 8GB. */
try {
Object.defineProperty(Navigator.prototype, 'deviceMemory', {
get: () => 8,
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",
"CanvasRenderingContext2D",
"toDataURL",
"AudioBuffer",
"getChannelData",
"RTCPeerConnection",
"hardwareConcurrency",
"deviceMemory",
];
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);
}
}