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,
SafariIphone,
SafariIpad,
SafariMacStable,
ChromeLinux,
BraveWindows,
OperaWindows,
SamsungInternetAndroid,
}
#[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(),
},
StealthProfile::SafariIphone => ProfileOverrides {
user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) \
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 \
Mobile/15E148 Safari/604.1"
.into(),
platform: "iPhone".into(),
languages: vec!["en-US".into(), "en".into()],
brands: Vec::new(),
mobile: true,
hardware_concurrency: 6,
device_memory: 4,
screen_width: 390,
screen_height: 844,
webgl_vendor: "Apple Inc.".into(),
webgl_renderer: "Apple GPU".into(),
},
StealthProfile::SafariIpad => ProfileOverrides {
user_agent: "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 \
(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
.into(),
platform: "iPad".into(),
languages: vec!["en-US".into(), "en".into()],
brands: Vec::new(),
mobile: true,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 1024,
screen_height: 1366,
webgl_vendor: "Apple Inc.".into(),
webgl_renderer: "Apple GPU".into(),
},
StealthProfile::SafariMacStable => ProfileOverrides {
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15"
.into(),
platform: "MacIntel".into(),
languages: vec!["en-US".into(), "en".into()],
brands: Vec::new(),
mobile: false,
hardware_concurrency: 10,
device_memory: 16,
screen_width: 1728,
screen_height: 1117,
webgl_vendor: "Apple Inc.".into(),
webgl_renderer: "Apple M2".into(),
},
StealthProfile::ChromeLinux => ProfileOverrides {
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
.into(),
platform: "Linux x86_64".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: "Mesa".into(),
webgl_renderer: "Mesa Intel(R) UHD Graphics 770 (ADL-S GT1)".into(),
},
StealthProfile::BraveWindows => 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![
("Brave".into(), "130".into()),
("Chromium".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: "Brave".into(),
webgl_renderer: "Brave".into(),
},
StealthProfile::OperaWindows => 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 OPR/115.0.0.0"
.into(),
platform: "Win32".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Chromium".into(), "130".into()),
("Opera".into(), "115".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 770 Direct3D11 vs_5_0 ps_5_0, D3D11)".into(),
},
StealthProfile::SamsungInternetAndroid => ProfileOverrides {
user_agent: "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 \
(KHTML, like Gecko) SamsungBrowser/26.0 Chrome/126.0.0.0 \
Mobile Safari/537.36"
.into(),
platform: "Linux armv8l".into(),
languages: vec!["en-US".into(), "en".into()],
brands: vec![
("Samsung Internet".into(), "26".into()),
("Chromium".into(), "126".into()),
("Not?A_Brand".into(), "99".into()),
],
mobile: true,
hardware_concurrency: 8,
device_memory: 8,
screen_width: 412,
screen_height: 915,
webgl_vendor: "Qualcomm".into(),
webgl_renderer: "Adreno (TM) 750".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)
.expect("serde_json::to_string(&String) is infallible"),
platform_json = serde_json::to_string(&overrides.platform)
.expect("serde_json::to_string(&String) is infallible"),
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)
.expect("serde_json::to_string(&String) is infallible"),
webgl_renderer_json = serde_json::to_string(&overrides.webgl_renderer)
.expect("serde_json::to_string(&String) is infallible"),
)
}
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,
StealthProfile::ChromeLinux,
StealthProfile::BraveWindows,
StealthProfile::OperaWindows,
StealthProfile::SamsungInternetAndroid,
] {
let ov = profile_to_overrides(&p);
assert!(!ov.brands.is_empty(), "{p:?} should declare userAgentData brands");
}
}
#[test]
fn safari_and_firefox_have_no_brands() {
for p in [
StealthProfile::FirefoxLinux,
StealthProfile::SafariIphone,
StealthProfile::SafariIpad,
StealthProfile::SafariMacStable,
] {
let ov = profile_to_overrides(&p);
assert!(
ov.brands.is_empty(),
"{p:?} does not expose userAgentData / Client Hints"
);
}
}
#[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"),
(StealthProfile::ChromeLinux, "Linux", "Linux x86_64"),
(StealthProfile::SafariIphone, "iPhone", "iPhone"),
(StealthProfile::SafariIpad, "iPad", "iPad"),
(StealthProfile::SafariMacStable, "Mac OS X", "MacIntel"),
(StealthProfile::BraveWindows, "Windows", "Win32"),
(StealthProfile::OperaWindows, "Windows", "Win32"),
(StealthProfile::SamsungInternetAndroid, "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,
StealthProfile::SafariIphone,
StealthProfile::SafariIpad,
StealthProfile::SafariMacStable,
StealthProfile::ChromeLinux,
StealthProfile::BraveWindows,
StealthProfile::OperaWindows,
StealthProfile::SamsungInternetAndroid,
] {
let ov = profile_to_overrides(&p);
assert_eq!(ov.languages.first().map(|s| s.as_str()), Some("en-US"));
}
}
#[test]
fn safari_profiles_have_apple_webgl_renderer() {
for p in [
StealthProfile::SafariIphone,
StealthProfile::SafariIpad,
StealthProfile::SafariMacStable,
] {
let ov = profile_to_overrides(&p);
assert!(
ov.webgl_vendor.contains("Apple"),
"{p:?} must claim an Apple GPU vendor"
);
}
}
#[test]
fn brave_profile_lists_brave_brand_first() {
let ov = profile_to_overrides(&StealthProfile::BraveWindows);
let first = ov.brands.first().map(|(b, _)| b.as_str());
assert_eq!(first, Some("Brave"));
}
#[test]
fn samsung_internet_profile_is_mobile() {
let ov = profile_to_overrides(&StealthProfile::SamsungInternetAndroid);
assert!(ov.mobile);
assert!(ov.user_agent.contains("SamsungBrowser"));
}
#[test]
fn opera_profile_lists_opera_brand() {
let ov = profile_to_overrides(&StealthProfile::OperaWindows);
let names: Vec<&str> = ov.brands.iter().map(|(b, _)| b.as_str()).collect();
assert!(names.contains(&"Opera"));
assert!(ov.user_agent.contains("OPR/"));
}
#[test]
fn safari_profiles_have_no_brave_or_opera_marker() {
for p in [
StealthProfile::SafariIphone,
StealthProfile::SafariIpad,
StealthProfile::SafariMacStable,
] {
let ov = profile_to_overrides(&p);
assert!(!ov.user_agent.contains("OPR/"));
assert!(!ov.user_agent.contains("Brave"));
assert!(ov.user_agent.contains("Safari/"));
}
}
#[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");
}
}