use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StealthConfig {
pub spoof_navigator: bool,
pub randomize_webgl: bool,
pub randomize_canvas: bool,
pub human_behavior: bool,
pub protect_cdp: bool,
}
impl Default for StealthConfig {
fn default() -> Self {
Self {
spoof_navigator: true,
randomize_webgl: true,
randomize_canvas: true,
human_behavior: true,
protect_cdp: true,
}
}
}
impl StealthConfig {
pub fn paranoid() -> Self {
Self::default()
}
pub const fn minimal() -> Self {
Self {
spoof_navigator: true,
randomize_webgl: false,
randomize_canvas: false,
human_behavior: false,
protect_cdp: true,
}
}
pub const fn disabled() -> Self {
Self {
spoof_navigator: false,
randomize_webgl: false,
randomize_canvas: false,
human_behavior: false,
protect_cdp: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavigatorProfile {
pub user_agent: String,
pub platform: String,
pub vendor: String,
pub hardware_concurrency: u8,
pub device_memory: u8,
pub max_touch_points: u8,
pub webgl_vendor: String,
pub webgl_renderer: String,
}
impl NavigatorProfile {
pub fn windows_chrome() -> Self {
Self {
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
.to_string(),
platform: "Win32".to_string(),
vendor: "Google Inc.".to_string(),
hardware_concurrency: 8,
device_memory: 8,
max_touch_points: 0,
webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
webgl_renderer:
"ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)"
.to_string(),
}
}
pub fn mac_chrome() -> Self {
Self {
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
.to_string(),
platform: "MacIntel".to_string(),
vendor: "Google Inc.".to_string(),
hardware_concurrency: 8,
device_memory: 8,
max_touch_points: 0,
webgl_vendor: "Google Inc. (Intel)".to_string(),
webgl_renderer: "ANGLE (Intel, Apple M1 Pro, OpenGL 4.1)".to_string(),
}
}
pub fn linux_chrome() -> Self {
Self {
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
.to_string(),
platform: "Linux x86_64".to_string(),
vendor: "Google Inc.".to_string(),
hardware_concurrency: 4,
device_memory: 4,
max_touch_points: 0,
webgl_vendor: "Mesa/X.org".to_string(),
webgl_renderer: "llvmpipe (LLVM 15.0.7, 256 bits)".to_string(),
}
}
}
impl Default for NavigatorProfile {
fn default() -> Self {
Self::mac_chrome()
}
}
pub struct StealthProfile {
config: StealthConfig,
navigator: NavigatorProfile,
}
impl StealthProfile {
pub const fn new(config: StealthConfig, navigator: NavigatorProfile) -> Self {
Self { config, navigator }
}
pub fn injection_script(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.config.spoof_navigator {
parts.push(self.navigator_spoof_script());
}
if self.config.randomize_webgl {
parts.push(self.webgl_spoof_script());
}
if self.config.spoof_navigator {
parts.push(Self::chrome_object_script());
parts.push(self.user_agent_data_script());
}
if parts.is_empty() {
return String::new();
}
format!(
"(function() {{\n 'use strict';\n{}\n}})();",
parts.join("\n\n")
)
}
fn navigator_spoof_script(&self) -> String {
let nav = &self.navigator;
format!(
r" // --- Navigator spoofing ---
(function() {{
const defineReadOnly = (target, prop, value) => {{
try {{
Object.defineProperty(target, prop, {{
get: () => value,
enumerable: true,
configurable: false,
}});
}} catch (_) {{}}
}};
// Remove the webdriver flag at both the prototype and instance levels.
// Cloudflare and pixelscan probe Navigator.prototype directly via
// Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').
// In real Chrome the property is enumerable:false — matching that is
// essential; enumerable:true is a Turnstile detection signal.
// configurable:true is kept so polyfills don't throw on a second
// defineProperty call.
try {{
Object.defineProperty(Navigator.prototype, 'webdriver', {{
get: () => undefined,
enumerable: false,
configurable: true,
}});
}} catch (_) {{}}
defineReadOnly(navigator, 'webdriver', undefined);
// Platform / identity
defineReadOnly(navigator, 'platform', {platform:?});
defineReadOnly(navigator, 'userAgent', {user_agent:?});
defineReadOnly(navigator, 'vendor', {vendor:?});
defineReadOnly(navigator, 'hardwareConcurrency', {hwc});
defineReadOnly(navigator, 'deviceMemory', {dm});
defineReadOnly(navigator, 'maxTouchPoints', {mtp});
// Permissions API — real browsers resolve 'notifications' as 'default'
if (navigator.permissions && navigator.permissions.query) {{
const origQuery = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = (params) => {{
if (params && params.name === 'notifications') {{
return Promise.resolve({{ state: Notification.permission, onchange: null }});
}}
return origQuery(params);
}};
}}
}})();",
platform = nav.platform,
user_agent = nav.user_agent,
vendor = nav.vendor,
hwc = nav.hardware_concurrency,
dm = nav.device_memory,
mtp = nav.max_touch_points,
)
}
fn chrome_object_script() -> String {
r" // --- window.chrome object spoofing ---
(function() {
if (!window.chrome) {
Object.defineProperty(window, 'chrome', {
value: {},
enumerable: true,
configurable: false,
writable: false,
});
}
const chrome = window.chrome;
// chrome.runtime — checked by Turnstile; needs at least an object with
// id and connect stubs to pass duck-type checks.
if (!chrome.runtime) {
chrome.runtime = {
id: undefined,
connect: () => {},
sendMessage: () => {},
onMessage: { addListener: () => {}, removeListener: () => {} },
};
}
// chrome.csi and chrome.loadTimes — legacy APIs present in real Chrome.
if (!chrome.csi) {
chrome.csi = () => ({
startE: Date.now(),
onloadT: Date.now(),
pageT: 0,
tran: 15,
});
}
if (!chrome.loadTimes) {
chrome.loadTimes = () => ({
requestTime: Date.now() / 1000,
startLoadTime: Date.now() / 1000,
commitLoadTime: Date.now() / 1000,
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
navigationType: 'Other',
wasFetchedViaSpdy: false,
wasNpnNegotiated: true,
npnNegotiatedProtocol: 'h2',
wasAlternateProtocolAvailable: false,
connectionInfo: 'h2',
});
}
})();"
.to_string()
}
fn user_agent_data_script(&self) -> String {
let nav = &self.navigator;
let version = nav
.user_agent
.split("Chrome/")
.nth(1)
.and_then(|s| s.split('.').next())
.unwrap_or("131");
let mobile = nav.max_touch_points > 0;
let platform = if nav.platform.contains("Win") {
"Windows"
} else if nav.platform.contains("Mac") {
"macOS"
} else {
"Linux"
};
format!(
r" // --- navigator.userAgentData spoofing ---
(function() {{
const uaData = {{
brands: [
{{ brand: 'Google Chrome', version: '{version}' }},
{{ brand: 'Chromium', version: '{version}' }},
{{ brand: 'Not=A?Brand', version: '99' }},
],
mobile: {mobile},
platform: '{platform}',
getHighEntropyValues: (hints) => Promise.resolve({{
brands: [
{{ brand: 'Google Chrome', version: '{version}' }},
{{ brand: 'Chromium', version: '{version}' }},
{{ brand: 'Not=A?Brand', version: '99' }},
],
mobile: {mobile},
platform: '{platform}',
architecture: 'x86',
bitness: '64',
model: '',
platformVersion: '10.0.0',
uaFullVersion: '{version}.0.0.0',
fullVersionList: [
{{ brand: 'Google Chrome', version: '{version}.0.0.0' }},
{{ brand: 'Chromium', version: '{version}.0.0.0' }},
{{ brand: 'Not=A?Brand', version: '99.0.0.0' }},
],
}}),
toJSON: () => ({{
brands: [
{{ brand: 'Google Chrome', version: '{version}' }},
{{ brand: 'Chromium', version: '{version}' }},
{{ brand: 'Not=A?Brand', version: '99' }},
],
mobile: {mobile},
platform: '{platform}',
}}),
}};
try {{
Object.defineProperty(navigator, 'userAgentData', {{
get: () => uaData,
enumerable: true,
configurable: false,
}});
}} catch (_) {{}}
}})();"
)
}
fn webgl_spoof_script(&self) -> String {
let nav = &self.navigator;
format!(
r" // --- WebGL fingerprint spoofing ---
(function() {{
const GL_VENDOR = 0x1F00;
const GL_RENDERER = 0x1F01;
const spoofCtx = (ctx) => {{
if (!ctx) return;
const origGetParam = ctx.getParameter.bind(ctx);
ctx.getParameter = (param) => {{
if (param === GL_VENDOR) return {webgl_vendor:?};
if (param === GL_RENDERER) return {webgl_renderer:?};
return origGetParam(param);
}};
}};
// Wrap HTMLCanvasElement.prototype.getContext
const origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
const ctx = origGetContext.call(this, type, ...args);
if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
spoofCtx(ctx);
}}
return ctx;
}};
}})();",
webgl_vendor = nav.webgl_vendor,
webgl_renderer = nav.webgl_renderer,
)
}
}
pub async fn apply_stealth_to_page(
page: &chromiumoxide::Page,
config: &crate::config::BrowserConfig,
) -> crate::error::Result<()> {
use crate::cdp_protection::CdpProtection;
use crate::config::StealthLevel;
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
async fn inject_one(
page: &chromiumoxide::Page,
op: &'static str,
source: String,
) -> crate::error::Result<()> {
use crate::error::BrowserError;
page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
source,
world_name: None,
include_command_line_api: None,
run_immediately: None,
})
.await
.map_err(|e| BrowserError::CdpError {
operation: op.to_string(),
message: e.to_string(),
})?;
Ok(())
}
if config.stealth_level == StealthLevel::None {
return Ok(());
}
let cdp_script =
CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
if !cdp_script.is_empty() {
inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
}
let (nav_profile, stealth_cfg) = match config.stealth_level {
StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
StealthLevel::Advanced => (
NavigatorProfile::windows_chrome(),
StealthConfig::paranoid(),
),
StealthLevel::None => unreachable!(),
};
let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
if !nav_script.is_empty() {
inject_one(
page,
"AddScriptToEvaluateOnNewDocument(navigator)",
nav_script,
)
.await?;
}
if config.stealth_level == StealthLevel::Advanced {
let fp = crate::fingerprint::Fingerprint::random();
let fp_script = crate::fingerprint::inject_fingerprint(&fp);
inject_one(
page,
"AddScriptToEvaluateOnNewDocument(fingerprint)",
fp_script,
)
.await?;
let webrtc_script = config.webrtc.injection_script();
if !webrtc_script.is_empty() {
inject_one(
page,
"AddScriptToEvaluateOnNewDocument(webrtc)",
webrtc_script,
)
.await?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_config_produces_empty_script() {
let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
assert_eq!(p.injection_script(), "");
}
#[test]
fn navigator_script_contains_platform() {
let profile = NavigatorProfile::windows_chrome();
let p = StealthProfile::new(StealthConfig::minimal(), profile);
let script = p.injection_script();
assert!(script.contains("Win32"), "platform must be in script");
assert!(
script.contains("'webdriver'"),
"webdriver removal must be present"
);
}
#[test]
fn navigator_script_contains_user_agent() {
let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
let script = p.injection_script();
assert!(script.contains("Mac OS X"));
assert!(script.contains("MacIntel"));
}
#[test]
fn webgl_script_contains_vendor_renderer() {
let p = StealthProfile::new(
StealthConfig {
spoof_navigator: false,
randomize_webgl: true,
..StealthConfig::disabled()
},
NavigatorProfile::windows_chrome(),
);
let script = p.injection_script();
assert!(
script.contains("NVIDIA"),
"WebGL vendor must appear in script"
);
assert!(
script.contains("getParameter"),
"WebGL method must be overridden"
);
}
#[test]
fn full_profile_wraps_in_iife() {
let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
let script = p.injection_script();
assert!(script.starts_with("(function()"), "script must be an IIFE");
assert!(script.ends_with("})();"));
}
#[test]
fn navigator_profile_linux_has_correct_platform() {
assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
}
#[test]
fn stealth_config_paranoid_equals_default() {
let a = StealthConfig::paranoid();
let b = StealthConfig::default();
assert_eq!(a.spoof_navigator, b.spoof_navigator);
assert_eq!(a.randomize_webgl, b.randomize_webgl);
assert_eq!(a.randomize_canvas, b.randomize_canvas);
assert_eq!(a.human_behavior, b.human_behavior);
assert_eq!(a.protect_cdp, b.protect_cdp);
}
#[test]
fn hardware_concurrency_reasonable() {
let p = NavigatorProfile::windows_chrome();
assert!(p.hardware_concurrency >= 2);
assert!(p.hardware_concurrency <= 64);
}
#[test]
fn none_level_is_not_active() {
use crate::config::StealthLevel;
assert!(!StealthLevel::None.is_active());
}
#[test]
fn basic_level_cdp_script_removes_webdriver() {
use crate::cdp_protection::{CdpFixMode, CdpProtection};
let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
assert!(
script.contains("webdriver"),
"CDP protection script should remove navigator.webdriver"
);
}
#[test]
fn basic_level_minimal_config_injects_navigator() {
let config = StealthConfig::minimal();
let profile = NavigatorProfile::default();
let script = StealthProfile::new(config, profile).injection_script();
assert!(
!script.is_empty(),
"Basic stealth should produce a navigator script"
);
}
#[test]
fn advanced_level_paranoid_config_includes_webgl() {
let config = StealthConfig::paranoid();
let profile = NavigatorProfile::windows_chrome();
let script = StealthProfile::new(config, profile).injection_script();
assert!(
script.contains("webgl") && script.contains("getParameter"),
"Advanced stealth should spoof WebGL via getParameter patching"
);
}
#[test]
fn advanced_level_fingerprint_script_non_empty() {
use crate::fingerprint::{Fingerprint, inject_fingerprint};
let fp = Fingerprint::random();
let script = inject_fingerprint(&fp);
assert!(
!script.is_empty(),
"Fingerprint injection script must not be empty"
);
}
#[test]
fn stealth_level_ordering() {
use crate::config::StealthLevel;
assert!(!StealthLevel::None.is_active());
assert!(StealthLevel::Basic.is_active());
assert!(StealthLevel::Advanced.is_active());
}
#[test]
fn navigator_profile_basic_uses_default() {
let profile = NavigatorProfile::default();
assert_eq!(profile.platform, "MacIntel");
}
#[test]
fn navigator_profile_advanced_uses_windows() {
let profile = NavigatorProfile::windows_chrome();
assert_eq!(profile.platform, "Win32");
}
}