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() -> 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 )
435 }
436
437 fn webgl_spoof_script(&self) -> String {
438 let nav = &self.navigator;
439
440 format!(
441 r" // --- WebGL fingerprint spoofing ---
442 (function() {{
443 const GL_VENDOR = 0x1F00;
444 const GL_RENDERER = 0x1F01;
445
446 const spoofCtx = (ctx) => {{
447 if (!ctx) return;
448 const origGetParam = ctx.getParameter.bind(ctx);
449 ctx.getParameter = (param) => {{
450 if (param === GL_VENDOR) return {webgl_vendor:?};
451 if (param === GL_RENDERER) return {webgl_renderer:?};
452 return origGetParam(param);
453 }};
454 }};
455
456 // Wrap HTMLCanvasElement.prototype.getContext
457 const origGetContext = HTMLCanvasElement.prototype.getContext;
458 HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
459 const ctx = origGetContext.call(this, type, ...args);
460 if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
461 spoofCtx(ctx);
462 }}
463 return ctx;
464 }};
465 }})();",
466 webgl_vendor = nav.webgl_vendor,
467 webgl_renderer = nav.webgl_renderer,
468 )
469 }
470}
471
472pub async fn apply_stealth_to_page(
504 page: &chromiumoxide::Page,
505 config: &crate::config::BrowserConfig,
506) -> crate::error::Result<()> {
507 use crate::cdp_protection::CdpProtection;
508 use crate::config::StealthLevel;
509 use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
510
511 async fn inject_one(
513 page: &chromiumoxide::Page,
514 op: &'static str,
515 source: String,
516 ) -> crate::error::Result<()> {
517 use crate::error::BrowserError;
518 page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
519 source,
520 world_name: None,
521 include_command_line_api: None,
522 run_immediately: None,
523 })
524 .await
525 .map_err(|e| BrowserError::CdpError {
526 operation: op.to_string(),
527 message: e.to_string(),
528 })?;
529 Ok(())
530 }
531
532 if config.stealth_level == StealthLevel::None {
533 return Ok(());
534 }
535
536 let cdp_script =
538 CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
539 if !cdp_script.is_empty() {
540 inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
541 }
542
543 let (nav_profile, stealth_cfg) = match config.stealth_level {
544 StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
545 StealthLevel::Advanced => (
546 NavigatorProfile::windows_chrome(),
547 StealthConfig::paranoid(),
548 ),
549 StealthLevel::None => unreachable!(),
550 };
551 let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
552 if !nav_script.is_empty() {
553 inject_one(
554 page,
555 "AddScriptToEvaluateOnNewDocument(navigator)",
556 nav_script,
557 )
558 .await?;
559 }
560
561 if config.stealth_level == StealthLevel::Advanced {
563 let fp = crate::fingerprint::Fingerprint::random();
564 let fp_script = crate::fingerprint::inject_fingerprint(&fp);
565 inject_one(
566 page,
567 "AddScriptToEvaluateOnNewDocument(fingerprint)",
568 fp_script,
569 )
570 .await?;
571
572 let webrtc_script = config.webrtc.injection_script();
573 if !webrtc_script.is_empty() {
574 inject_one(
575 page,
576 "AddScriptToEvaluateOnNewDocument(webrtc)",
577 webrtc_script,
578 )
579 .await?;
580 }
581 }
582
583 Ok(())
584}
585
586#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn disabled_config_produces_empty_script() {
594 let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
595 assert_eq!(p.injection_script(), "");
596 }
597
598 #[test]
599 fn navigator_script_contains_platform() {
600 let profile = NavigatorProfile::windows_chrome();
601 let p = StealthProfile::new(StealthConfig::minimal(), profile);
602 let script = p.injection_script();
603 assert!(script.contains("Win32"), "platform must be in script");
604 assert!(
605 script.contains("'webdriver'"),
606 "webdriver removal must be present"
607 );
608 }
609
610 #[test]
611 fn navigator_script_contains_user_agent() {
612 let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
613 let script = p.injection_script();
614 assert!(script.contains("Mac OS X"));
615 assert!(script.contains("MacIntel"));
616 }
617
618 #[test]
619 fn webgl_script_contains_vendor_renderer() {
620 let p = StealthProfile::new(
621 StealthConfig {
622 spoof_navigator: false,
623 randomize_webgl: true,
624 ..StealthConfig::disabled()
625 },
626 NavigatorProfile::windows_chrome(),
627 );
628 let script = p.injection_script();
629 assert!(
630 script.contains("NVIDIA"),
631 "WebGL vendor must appear in script"
632 );
633 assert!(
634 script.contains("getParameter"),
635 "WebGL method must be overridden"
636 );
637 }
638
639 #[test]
640 fn full_profile_wraps_in_iife() {
641 let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
642 let script = p.injection_script();
643 assert!(script.starts_with("(function()"), "script must be an IIFE");
644 assert!(script.ends_with("})();"));
645 }
646
647 #[test]
648 fn navigator_profile_linux_has_correct_platform() {
649 assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
650 }
651
652 #[test]
653 fn stealth_config_paranoid_equals_default() {
654 let a = StealthConfig::paranoid();
655 let b = StealthConfig::default();
656 assert_eq!(a.spoof_navigator, b.spoof_navigator);
657 assert_eq!(a.randomize_webgl, b.randomize_webgl);
658 assert_eq!(a.randomize_canvas, b.randomize_canvas);
659 assert_eq!(a.human_behavior, b.human_behavior);
660 assert_eq!(a.protect_cdp, b.protect_cdp);
661 }
662
663 #[test]
664 fn hardware_concurrency_reasonable() {
665 let p = NavigatorProfile::windows_chrome();
666 assert!(p.hardware_concurrency >= 2);
667 assert!(p.hardware_concurrency <= 64);
668 }
669
670 #[test]
673 fn none_level_is_not_active() {
674 use crate::config::StealthLevel;
675 assert!(!StealthLevel::None.is_active());
676 }
677
678 #[test]
679 fn basic_level_cdp_script_removes_webdriver() {
680 use crate::cdp_protection::{CdpFixMode, CdpProtection};
681 let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
682 assert!(
683 script.contains("webdriver"),
684 "CDP protection script should remove navigator.webdriver"
685 );
686 }
687
688 #[test]
689 fn basic_level_minimal_config_injects_navigator() {
690 let config = StealthConfig::minimal();
691 let profile = NavigatorProfile::default();
692 let script = StealthProfile::new(config, profile).injection_script();
693 assert!(
694 !script.is_empty(),
695 "Basic stealth should produce a navigator script"
696 );
697 }
698
699 #[test]
700 fn advanced_level_paranoid_config_includes_webgl() {
701 let config = StealthConfig::paranoid();
702 let profile = NavigatorProfile::windows_chrome();
703 let script = StealthProfile::new(config, profile).injection_script();
704 assert!(
705 script.contains("webgl") && script.contains("getParameter"),
706 "Advanced stealth should spoof WebGL via getParameter patching"
707 );
708 }
709
710 #[test]
711 fn advanced_level_fingerprint_script_non_empty() {
712 use crate::fingerprint::{Fingerprint, inject_fingerprint};
713 let fp = Fingerprint::random();
714 let script = inject_fingerprint(&fp);
715 assert!(
716 !script.is_empty(),
717 "Fingerprint injection script must not be empty"
718 );
719 }
720
721 #[test]
722 fn stealth_level_ordering() {
723 use crate::config::StealthLevel;
724 assert!(!StealthLevel::None.is_active());
725 assert!(StealthLevel::Basic.is_active());
726 assert!(StealthLevel::Advanced.is_active());
727 }
728
729 #[test]
730 fn navigator_profile_basic_uses_default() {
731 let profile = NavigatorProfile::default();
733 assert_eq!(profile.platform, "MacIntel");
734 }
735
736 #[test]
737 fn navigator_profile_advanced_uses_windows() {
738 let profile = NavigatorProfile::windows_chrome();
739 assert_eq!(profile.platform, "Win32");
740 }
741}