use anyhow::{anyhow, Result};
use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
use chromiumoxide::Page;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StealthProfile {
ChromeWindowsStable,
ChromeMacStable,
EdgeWindowsStable,
FirefoxLinux,
ChromeAndroid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileOverrides {
pub user_agent: String,
pub platform: String,
pub languages: Vec<String>,
pub brands: Vec<(String, String)>,
pub mobile: bool,
pub hardware_concurrency: u32,
pub device_memory: u32,
pub screen_width: u32,
pub screen_height: u32,
pub webgl_vendor: String,
pub webgl_renderer: String,
}
pub fn profile_to_overrides(profile: &StealthProfile) -> ProfileOverrides {
match profile {
StealthProfile::ChromeWindowsStable => ProfileOverrides {
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
.into(),
platform: "Win32".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Chromium".into(), "130".into()),
("Google Chrome".into(), "130".into()),
("Not?A_Brand".into(), "99".into()),
],
mobile: false,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 1920,
screen_height: 1080,
webgl_vendor: "Google Inc. (Intel)".into(),
webgl_renderer: "ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
},
StealthProfile::ChromeMacStable => ProfileOverrides {
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
.into(),
platform: "MacIntel".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Chromium".into(), "130".into()),
("Google Chrome".into(), "130".into()),
("Not?A_Brand".into(), "99".into()),
],
mobile: false,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 1728,
screen_height: 1117,
webgl_vendor: "Google Inc. (Apple)".into(),
webgl_renderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)".into(),
},
StealthProfile::EdgeWindowsStable => ProfileOverrides {
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0"
.into(),
platform: "Win32".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Chromium".into(), "130".into()),
("Microsoft Edge".into(), "130".into()),
("Not?A_Brand".into(), "99".into()),
],
mobile: false,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 1920,
screen_height: 1080,
webgl_vendor: "Google Inc. (Intel)".into(),
webgl_renderer: "ANGLE (Intel, Intel(R) UHD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
},
StealthProfile::FirefoxLinux => ProfileOverrides {
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
.into(),
platform: "Linux x86_64".into(),
languages: vec!["en-US".into(), "en".into()],
brands: Vec::new(),
mobile: false,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 1920,
screen_height: 1080,
webgl_vendor: "Mesa".into(),
webgl_renderer: "Mesa Intel(R) Iris(R) Xe Graphics (TGL GT2)".into(),
},
StealthProfile::ChromeAndroid => ProfileOverrides {
user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
.into(),
platform: "Linux armv8l".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Chromium".into(), "130".into()),
("Google Chrome".into(), "130".into()),
("Not?A_Brand".into(), "99".into()),
],
mobile: true,
hardware_concurrency: 8,
device_memory: 6,
screen_width: 412,
screen_height: 915,
webgl_vendor: "Qualcomm".into(),
webgl_renderer: "Adreno (TM) 740".into(),
},
}
}
pub fn profile_js(overrides: &ProfileOverrides) -> String {
let langs_json = serde_json::to_string(&overrides.languages)
.unwrap_or_else(|_| r#"["en-US","en"]"#.into());
let brands_json = serde_json::to_string(
&overrides
.brands
.iter()
.map(|(b, v)| serde_json::json!({"brand": b, "version": v}))
.collect::<Vec<_>>(),
)
.unwrap_or_else(|_| "[]".into());
format!(
r#"
(() => {{
/* User-Agent. Overrides both userAgent and appVersion since
some detectors look for divergence between them. */
try {{
Object.defineProperty(Navigator.prototype, 'userAgent', {{
get: () => {ua_json},
configurable: true,
}});
Object.defineProperty(Navigator.prototype, 'appVersion', {{
get: () => {ua_json}.replace('Mozilla/', ''),
configurable: true,
}});
}} catch (_) {{}}
/* navigator.platform — must agree with UA. Detectors flag
(UA: Windows, platform: MacIntel) as obvious spoofing. */
try {{
Object.defineProperty(Navigator.prototype, 'platform', {{
get: () => {platform_json},
configurable: true,
}});
}} catch (_) {{}}
/* navigator.languages */
try {{
Object.defineProperty(Navigator.prototype, 'languages', {{
get: () => {langs_json},
configurable: true,
}});
}} catch (_) {{}}
/* userAgentData — Chromium-only. Skip when brands is empty
(Firefox profile). */
try {{
const brands = {brands_json};
if (brands.length > 0) {{
Object.defineProperty(Navigator.prototype, 'userAgentData', {{
get: () => ({{
brands,
mobile: {mobile},
platform: {platform_json},
getHighEntropyValues: (hints) => Promise.resolve({{
brands,
mobile: {mobile},
platform: {platform_json},
platformVersion: '15.0.0',
architecture: 'x86',
bitness: '64',
model: '',
uaFullVersion: '130.0.0.0',
}}),
toJSON: () => ({{ brands, mobile: {mobile}, platform: {platform_json} }}),
}}),
configurable: true,
}});
}}
}} catch (_) {{}}
/* hardwareConcurrency / deviceMemory — coherent with the
claimed device class. */
try {{
Object.defineProperty(Navigator.prototype, 'hardwareConcurrency', {{
get: () => {hwc},
configurable: true,
}});
Object.defineProperty(Navigator.prototype, 'deviceMemory', {{
get: () => {dm},
configurable: true,
}});
}} catch (_) {{}}
/* screen.width/height. Detectors compute device-pixel ratio
and screen aspect — pinning to known-real values blends in. */
try {{
Object.defineProperty(window.screen, 'width', {{
get: () => {sw},
configurable: true,
}});
Object.defineProperty(window.screen, 'height', {{
get: () => {sh},
configurable: true,
}});
Object.defineProperty(window.screen, 'availWidth', {{
get: () => {sw},
configurable: true,
}});
Object.defineProperty(window.screen, 'availHeight', {{
get: () => {sh} - 40,
configurable: true,
}});
}} catch (_) {{}}
/* WebGL UNMASKED_* parameters. Coherent with UA platform. */
try {{
const VEND = 0x9245, REND = 0x9246;
const wrap = (proto) => {{
const orig = proto.getParameter;
proto.getParameter = function(p) {{
if (p === VEND) return {webgl_vendor_json};
if (p === REND) return {webgl_renderer_json};
return orig.call(this, p);
}};
}};
if (typeof WebGLRenderingContext !== 'undefined') wrap(WebGLRenderingContext.prototype);
if (typeof WebGL2RenderingContext !== 'undefined') wrap(WebGL2RenderingContext.prototype);
}} catch (_) {{}}
}})();
"#,
ua_json = serde_json::to_string(&overrides.user_agent).unwrap(),
platform_json = serde_json::to_string(&overrides.platform).unwrap(),
langs_json = langs_json,
brands_json = brands_json,
mobile = overrides.mobile,
hwc = overrides.hardware_concurrency,
dm = overrides.device_memory,
sw = overrides.screen_width,
sh = overrides.screen_height,
webgl_vendor_json = serde_json::to_string(&overrides.webgl_vendor).unwrap(),
webgl_renderer_json = serde_json::to_string(&overrides.webgl_renderer).unwrap(),
)
}
pub async fn apply_stealth_profile(page: &Page, profile: &StealthProfile) -> Result<()> {
let overrides = profile_to_overrides(profile);
let js = profile_js(&overrides);
page.execute(AddScriptToEvaluateOnNewDocumentParams {
source: js,
world_name: None,
include_command_line_api: None,
run_immediately: Some(true),
})
.await
.map_err(|e| anyhow!("stealth_profile: addScriptToEvaluateOnNewDocument failed: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_chromium_profile_has_brands() {
for p in [
StealthProfile::ChromeWindowsStable,
StealthProfile::ChromeMacStable,
StealthProfile::EdgeWindowsStable,
StealthProfile::ChromeAndroid,
] {
let ov = profile_to_overrides(&p);
assert!(!ov.brands.is_empty(), "{p:?} should declare userAgentData brands");
}
}
#[test]
fn firefox_profile_has_no_brands() {
let ov = profile_to_overrides(&StealthProfile::FirefoxLinux);
assert!(ov.brands.is_empty(), "Firefox does not expose userAgentData");
}
#[test]
fn ua_matches_platform() {
let cases = [
(StealthProfile::ChromeWindowsStable, "Windows", "Win32"),
(StealthProfile::ChromeMacStable, "Mac OS X", "MacIntel"),
(StealthProfile::EdgeWindowsStable, "Windows", "Win32"),
(StealthProfile::FirefoxLinux, "Linux", "Linux x86_64"),
(StealthProfile::ChromeAndroid, "Android", "Linux armv8l"),
];
for (p, ua_substr, platform) in cases {
let ov = profile_to_overrides(&p);
assert!(ov.user_agent.contains(ua_substr), "{p:?} UA missing {ua_substr}");
assert_eq!(ov.platform, platform, "{p:?} platform mismatch");
}
}
#[test]
fn profile_js_emits_uadata_only_when_brands_present() {
let chrome = profile_js(&profile_to_overrides(&StealthProfile::ChromeWindowsStable));
assert!(chrome.contains("userAgentData"));
assert!(chrome.contains("brands.length > 0"));
}
#[test]
fn profile_js_overrides_webgl_unmasked_params() {
let js = profile_js(&profile_to_overrides(&StealthProfile::ChromeMacStable));
assert!(js.contains("0x9245"), "WebGL UNMASKED_VENDOR constant must be referenced");
assert!(js.contains("0x9246"), "WebGL UNMASKED_RENDERER constant must be referenced");
assert!(js.contains("Apple M1 Pro"), "Mac profile must pin an Apple GPU renderer");
}
#[test]
fn android_profile_is_mobile_with_narrow_screen() {
let ov = profile_to_overrides(&StealthProfile::ChromeAndroid);
assert!(ov.mobile);
assert!(ov.screen_width < 500, "mobile profile should have a phone-sized screen");
}
#[test]
fn languages_default_to_english() {
for p in [
StealthProfile::ChromeWindowsStable,
StealthProfile::ChromeMacStable,
StealthProfile::EdgeWindowsStable,
StealthProfile::FirefoxLinux,
StealthProfile::ChromeAndroid,
] {
let ov = profile_to_overrides(&p);
assert_eq!(ov.languages.first().map(|s| s.as_str()), Some("en-US"));
}
}
#[test]
fn edge_profile_lists_microsoft_brand_distinct_from_chrome() {
let ov = profile_to_overrides(&StealthProfile::EdgeWindowsStable);
let brand_names: Vec<&str> = ov.brands.iter().map(|(b, _)| b.as_str()).collect();
assert!(brand_names.contains(&"Microsoft Edge"));
assert!(!brand_names.contains(&"Google Chrome"),
"Edge profile must not list Google Chrome — that mismatch is itself a tell");
}
}