1use serde::{Deserialize, Serialize};
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct StealthConfig {
41 pub spoof_navigator: bool,
43 pub randomize_webgl: bool,
45 pub randomize_canvas: bool,
47 pub human_behavior: bool,
49 pub protect_cdp: bool,
51}
52
53impl Default for StealthConfig {
54 fn default() -> Self {
55 Self {
56 spoof_navigator: true,
57 randomize_webgl: true,
58 randomize_canvas: true,
59 human_behavior: true,
60 protect_cdp: true,
61 }
62 }
63}
64
65impl StealthConfig {
66 pub fn paranoid() -> Self {
68 Self::default()
69 }
70
71 pub const fn minimal() -> Self {
73 Self {
74 spoof_navigator: true,
75 randomize_webgl: false,
76 randomize_canvas: false,
77 human_behavior: false,
78 protect_cdp: true,
79 }
80 }
81
82 pub const fn disabled() -> Self {
84 Self {
85 spoof_navigator: false,
86 randomize_webgl: false,
87 randomize_canvas: false,
88 human_behavior: false,
89 protect_cdp: false,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct NavigatorProfile {
112 pub user_agent: String,
114 pub platform: String,
116 pub vendor: String,
118 pub hardware_concurrency: u8,
120 pub device_memory: u8,
122 pub max_touch_points: u8,
124 pub webgl_vendor: String,
126 pub webgl_renderer: String,
128}
129
130impl NavigatorProfile {
131 pub fn windows_chrome() -> Self {
133 Self {
134 user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
135 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
136 .to_string(),
137 platform: "Win32".to_string(),
138 vendor: "Google Inc.".to_string(),
139 hardware_concurrency: 8,
140 device_memory: 8,
141 max_touch_points: 0,
142 webgl_vendor: "Google Inc. (NVIDIA)".to_string(),
143 webgl_renderer:
144 "ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)"
145 .to_string(),
146 }
147 }
148
149 pub fn mac_chrome() -> Self {
151 Self {
152 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
153 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
154 .to_string(),
155 platform: "MacIntel".to_string(),
156 vendor: "Google Inc.".to_string(),
157 hardware_concurrency: 8,
158 device_memory: 8,
159 max_touch_points: 0,
160 webgl_vendor: "Google Inc. (Intel)".to_string(),
161 webgl_renderer: "ANGLE (Intel, Apple M1 Pro, OpenGL 4.1)".to_string(),
162 }
163 }
164
165 pub fn linux_chrome() -> Self {
167 Self {
168 user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \
169 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
170 .to_string(),
171 platform: "Linux x86_64".to_string(),
172 vendor: "Google Inc.".to_string(),
173 hardware_concurrency: 4,
174 device_memory: 4,
175 max_touch_points: 0,
176 webgl_vendor: "Mesa/X.org".to_string(),
177 webgl_renderer: "llvmpipe (LLVM 15.0.7, 256 bits)".to_string(),
178 }
179 }
180}
181
182impl Default for NavigatorProfile {
183 fn default() -> Self {
184 Self::mac_chrome()
185 }
186}
187
188pub struct StealthProfile {
204 config: StealthConfig,
205 navigator: NavigatorProfile,
206}
207
208impl StealthProfile {
209 pub const fn new(config: StealthConfig, navigator: NavigatorProfile) -> Self {
211 Self { config, navigator }
212 }
213
214 pub fn injection_script(&self) -> String {
219 let mut parts: Vec<String> = Vec::new();
220
221 if self.config.spoof_navigator {
222 parts.push(self.navigator_spoof_script());
223 }
224
225 if self.config.randomize_webgl {
226 parts.push(self.webgl_spoof_script());
227 }
228
229 if self.config.spoof_navigator {
232 parts.push(self.chrome_object_script());
233 parts.push(self.user_agent_data_script());
234 }
235
236 if parts.is_empty() {
237 return String::new();
238 }
239
240 format!(
242 "(function() {{\n 'use strict';\n{}\n}})();",
243 parts.join("\n\n")
244 )
245 }
246
247 fn navigator_spoof_script(&self) -> String {
250 let nav = &self.navigator;
251
252 format!(
255 r" // --- Navigator spoofing ---
256 (function() {{
257 const defineReadOnly = (target, prop, value) => {{
258 try {{
259 Object.defineProperty(target, prop, {{
260 get: () => value,
261 enumerable: true,
262 configurable: false,
263 }});
264 }} catch (_) {{}}
265 }};
266
267 // Remove the webdriver flag at both the prototype and instance levels.
268 // Cloudflare and pixelscan probe Navigator.prototype directly via
269 // Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver').
270 // In real Chrome the property is enumerable:false — matching that is
271 // essential; enumerable:true is a Turnstile detection signal.
272 // configurable:true is kept so polyfills don't throw on a second
273 // defineProperty call.
274 try {{
275 Object.defineProperty(Navigator.prototype, 'webdriver', {{
276 get: () => undefined,
277 enumerable: false,
278 configurable: true,
279 }});
280 }} catch (_) {{}}
281 defineReadOnly(navigator, 'webdriver', undefined);
282
283 // Platform / identity
284 defineReadOnly(navigator, 'platform', {platform:?});
285 defineReadOnly(navigator, 'userAgent', {user_agent:?});
286 defineReadOnly(navigator, 'vendor', {vendor:?});
287 defineReadOnly(navigator, 'hardwareConcurrency', {hwc});
288 defineReadOnly(navigator, 'deviceMemory', {dm});
289 defineReadOnly(navigator, 'maxTouchPoints', {mtp});
290
291 // Permissions API — real browsers resolve 'notifications' as 'default'
292 if (navigator.permissions && navigator.permissions.query) {{
293 const origQuery = navigator.permissions.query.bind(navigator.permissions);
294 navigator.permissions.query = (params) => {{
295 if (params && params.name === 'notifications') {{
296 return Promise.resolve({{ state: Notification.permission, onchange: null }});
297 }}
298 return origQuery(params);
299 }};
300 }}
301 }})();",
302 platform = nav.platform,
303 user_agent = nav.user_agent,
304 vendor = nav.vendor,
305 hwc = nav.hardware_concurrency,
306 dm = nav.device_memory,
307 mtp = nav.max_touch_points,
308 )
309 }
310
311 fn chrome_object_script(&self) -> String {
312 r" // --- window.chrome object spoofing ---
316 (function() {
317 if (!window.chrome) {
318 Object.defineProperty(window, 'chrome', {
319 value: {},
320 enumerable: true,
321 configurable: false,
322 writable: false,
323 });
324 }
325 const chrome = window.chrome;
326 // chrome.runtime — checked by Turnstile; needs at least an object with
327 // id and connect stubs to pass duck-type checks.
328 if (!chrome.runtime) {
329 chrome.runtime = {
330 id: undefined,
331 connect: () => {},
332 sendMessage: () => {},
333 onMessage: { addListener: () => {}, removeListener: () => {} },
334 };
335 }
336 // chrome.csi and chrome.loadTimes — legacy APIs present in real Chrome.
337 if (!chrome.csi) {
338 chrome.csi = () => ({
339 startE: Date.now(),
340 onloadT: Date.now(),
341 pageT: 0,
342 tran: 15,
343 });
344 }
345 if (!chrome.loadTimes) {
346 chrome.loadTimes = () => ({
347 requestTime: Date.now() / 1000,
348 startLoadTime: Date.now() / 1000,
349 commitLoadTime: Date.now() / 1000,
350 finishDocumentLoadTime: Date.now() / 1000,
351 finishLoadTime: Date.now() / 1000,
352 firstPaintTime: Date.now() / 1000,
353 firstPaintAfterLoadTime: 0,
354 navigationType: 'Other',
355 wasFetchedViaSpdy: false,
356 wasNpnNegotiated: true,
357 npnNegotiatedProtocol: 'h2',
358 wasAlternateProtocolAvailable: false,
359 connectionInfo: 'h2',
360 });
361 }
362 })();"
363 .to_string()
364 }
365
366 fn user_agent_data_script(&self) -> String {
367 let nav = &self.navigator;
368 let version = nav
372 .user_agent
373 .split("Chrome/")
374 .nth(1)
375 .and_then(|s| s.split('.').next())
376 .unwrap_or("131");
377 let mobile = nav.max_touch_points > 0;
378 let platform = if nav.platform.contains("Win") {
379 "Windows"
380 } else if nav.platform.contains("Mac") {
381 "macOS"
382 } else {
383 "Linux"
384 };
385
386 format!(
387 r#" // --- navigator.userAgentData spoofing ---
388 (function() {{
389 const uaData = {{
390 brands: [
391 {{ brand: 'Google Chrome', version: '{version}' }},
392 {{ brand: 'Chromium', version: '{version}' }},
393 {{ brand: 'Not=A?Brand', version: '99' }},
394 ],
395 mobile: {mobile},
396 platform: '{platform}',
397 getHighEntropyValues: (hints) => Promise.resolve({{
398 brands: [
399 {{ brand: 'Google Chrome', version: '{version}' }},
400 {{ brand: 'Chromium', version: '{version}' }},
401 {{ brand: 'Not=A?Brand', version: '99' }},
402 ],
403 mobile: {mobile},
404 platform: '{platform}',
405 architecture: 'x86',
406 bitness: '64',
407 model: '',
408 platformVersion: '10.0.0',
409 uaFullVersion: '{version}.0.0.0',
410 fullVersionList: [
411 {{ brand: 'Google Chrome', version: '{version}.0.0.0' }},
412 {{ brand: 'Chromium', version: '{version}.0.0.0' }},
413 {{ brand: 'Not=A?Brand', version: '99.0.0.0' }},
414 ],
415 }}),
416 toJSON: () => ({{
417 brands: [
418 {{ brand: 'Google Chrome', version: '{version}' }},
419 {{ brand: 'Chromium', version: '{version}' }},
420 {{ brand: 'Not=A?Brand', version: '99' }},
421 ],
422 mobile: {mobile},
423 platform: '{platform}',
424 }}),
425 }};
426 try {{
427 Object.defineProperty(navigator, 'userAgentData', {{
428 get: () => uaData,
429 enumerable: true,
430 configurable: false,
431 }});
432 }} catch (_) {{}}
433 }})();"#,
434 version = version,
435 mobile = mobile,
436 platform = platform,
437 )
438 }
439
440 fn webgl_spoof_script(&self) -> String {
441 let nav = &self.navigator;
442
443 format!(
444 r" // --- WebGL fingerprint spoofing ---
445 (function() {{
446 const GL_VENDOR = 0x1F00;
447 const GL_RENDERER = 0x1F01;
448
449 const spoofCtx = (ctx) => {{
450 if (!ctx) return;
451 const origGetParam = ctx.getParameter.bind(ctx);
452 ctx.getParameter = (param) => {{
453 if (param === GL_VENDOR) return {webgl_vendor:?};
454 if (param === GL_RENDERER) return {webgl_renderer:?};
455 return origGetParam(param);
456 }};
457 }};
458
459 // Wrap HTMLCanvasElement.prototype.getContext
460 const origGetContext = HTMLCanvasElement.prototype.getContext;
461 HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
462 const ctx = origGetContext.call(this, type, ...args);
463 if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
464 spoofCtx(ctx);
465 }}
466 return ctx;
467 }};
468 }})();",
469 webgl_vendor = nav.webgl_vendor,
470 webgl_renderer = nav.webgl_renderer,
471 )
472 }
473}
474
475pub async fn apply_stealth_to_page(
507 page: &chromiumoxide::Page,
508 config: &crate::config::BrowserConfig,
509) -> crate::error::Result<()> {
510 use crate::cdp_protection::CdpProtection;
511 use crate::config::StealthLevel;
512 use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
513
514 async fn inject_one(
516 page: &chromiumoxide::Page,
517 op: &'static str,
518 source: String,
519 ) -> crate::error::Result<()> {
520 use crate::error::BrowserError;
521 page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
522 source,
523 world_name: None,
524 include_command_line_api: None,
525 run_immediately: None,
526 })
527 .await
528 .map_err(|e| BrowserError::CdpError {
529 operation: op.to_string(),
530 message: e.to_string(),
531 })?;
532 Ok(())
533 }
534
535 if config.stealth_level == StealthLevel::None {
536 return Ok(());
537 }
538
539 let cdp_script =
541 CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
542 if !cdp_script.is_empty() {
543 inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
544 }
545
546 let (nav_profile, stealth_cfg) = match config.stealth_level {
547 StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
548 StealthLevel::Advanced => (
549 NavigatorProfile::windows_chrome(),
550 StealthConfig::paranoid(),
551 ),
552 StealthLevel::None => unreachable!(),
553 };
554 let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
555 if !nav_script.is_empty() {
556 inject_one(
557 page,
558 "AddScriptToEvaluateOnNewDocument(navigator)",
559 nav_script,
560 )
561 .await?;
562 }
563
564 if config.stealth_level == StealthLevel::Advanced {
566 let fp = crate::fingerprint::Fingerprint::random();
567 let fp_script = crate::fingerprint::inject_fingerprint(&fp);
568 inject_one(
569 page,
570 "AddScriptToEvaluateOnNewDocument(fingerprint)",
571 fp_script,
572 )
573 .await?;
574
575 let webrtc_script = config.webrtc.injection_script();
576 if !webrtc_script.is_empty() {
577 inject_one(
578 page,
579 "AddScriptToEvaluateOnNewDocument(webrtc)",
580 webrtc_script,
581 )
582 .await?;
583 }
584 }
585
586 Ok(())
587}
588
589#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn disabled_config_produces_empty_script() {
597 let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
598 assert_eq!(p.injection_script(), "");
599 }
600
601 #[test]
602 fn navigator_script_contains_platform() {
603 let profile = NavigatorProfile::windows_chrome();
604 let p = StealthProfile::new(StealthConfig::minimal(), profile);
605 let script = p.injection_script();
606 assert!(script.contains("Win32"), "platform must be in script");
607 assert!(
608 script.contains("'webdriver'"),
609 "webdriver removal must be present"
610 );
611 }
612
613 #[test]
614 fn navigator_script_contains_user_agent() {
615 let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
616 let script = p.injection_script();
617 assert!(script.contains("Mac OS X"));
618 assert!(script.contains("MacIntel"));
619 }
620
621 #[test]
622 fn webgl_script_contains_vendor_renderer() {
623 let p = StealthProfile::new(
624 StealthConfig {
625 spoof_navigator: false,
626 randomize_webgl: true,
627 ..StealthConfig::disabled()
628 },
629 NavigatorProfile::windows_chrome(),
630 );
631 let script = p.injection_script();
632 assert!(
633 script.contains("NVIDIA"),
634 "WebGL vendor must appear in script"
635 );
636 assert!(
637 script.contains("getParameter"),
638 "WebGL method must be overridden"
639 );
640 }
641
642 #[test]
643 fn full_profile_wraps_in_iife() {
644 let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
645 let script = p.injection_script();
646 assert!(script.starts_with("(function()"), "script must be an IIFE");
647 assert!(script.ends_with("})();"));
648 }
649
650 #[test]
651 fn navigator_profile_linux_has_correct_platform() {
652 assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
653 }
654
655 #[test]
656 fn stealth_config_paranoid_equals_default() {
657 let a = StealthConfig::paranoid();
658 let b = StealthConfig::default();
659 assert_eq!(a.spoof_navigator, b.spoof_navigator);
660 assert_eq!(a.randomize_webgl, b.randomize_webgl);
661 assert_eq!(a.randomize_canvas, b.randomize_canvas);
662 assert_eq!(a.human_behavior, b.human_behavior);
663 assert_eq!(a.protect_cdp, b.protect_cdp);
664 }
665
666 #[test]
667 fn hardware_concurrency_reasonable() {
668 let p = NavigatorProfile::windows_chrome();
669 assert!(p.hardware_concurrency >= 2);
670 assert!(p.hardware_concurrency <= 64);
671 }
672
673 #[test]
676 fn none_level_is_not_active() {
677 use crate::config::StealthLevel;
678 assert!(!StealthLevel::None.is_active());
679 }
680
681 #[test]
682 fn basic_level_cdp_script_removes_webdriver() {
683 use crate::cdp_protection::{CdpFixMode, CdpProtection};
684 let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
685 assert!(
686 script.contains("webdriver"),
687 "CDP protection script should remove navigator.webdriver"
688 );
689 }
690
691 #[test]
692 fn basic_level_minimal_config_injects_navigator() {
693 let config = StealthConfig::minimal();
694 let profile = NavigatorProfile::default();
695 let script = StealthProfile::new(config, profile).injection_script();
696 assert!(
697 !script.is_empty(),
698 "Basic stealth should produce a navigator script"
699 );
700 }
701
702 #[test]
703 fn advanced_level_paranoid_config_includes_webgl() {
704 let config = StealthConfig::paranoid();
705 let profile = NavigatorProfile::windows_chrome();
706 let script = StealthProfile::new(config, profile).injection_script();
707 assert!(
708 script.contains("webgl") && script.contains("getParameter"),
709 "Advanced stealth should spoof WebGL via getParameter patching"
710 );
711 }
712
713 #[test]
714 fn advanced_level_fingerprint_script_non_empty() {
715 use crate::fingerprint::{Fingerprint, inject_fingerprint};
716 let fp = Fingerprint::random();
717 let script = inject_fingerprint(&fp);
718 assert!(
719 !script.is_empty(),
720 "Fingerprint injection script must not be empty"
721 );
722 }
723
724 #[test]
725 fn stealth_level_ordering() {
726 use crate::config::StealthLevel;
727 assert!(!StealthLevel::None.is_active());
728 assert!(StealthLevel::Basic.is_active());
729 assert!(StealthLevel::Advanced.is_active());
730 }
731
732 #[test]
733 fn navigator_profile_basic_uses_default() {
734 let profile = NavigatorProfile::default();
736 assert_eq!(profile.platform, "MacIntel");
737 }
738
739 #[test]
740 fn navigator_profile_advanced_uses_windows() {
741 let profile = NavigatorProfile::windows_chrome();
742 assert_eq!(profile.platform, "Win32");
743 }
744}