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/120.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/120.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/120.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 parts.is_empty() {
230 return String::new();
231 }
232
233 format!(
235 "(function() {{\n 'use strict';\n{}\n}})();",
236 parts.join("\n\n")
237 )
238 }
239
240 fn navigator_spoof_script(&self) -> String {
243 let nav = &self.navigator;
244
245 format!(
248 r" // --- Navigator spoofing ---
249 (function() {{
250 const defineReadOnly = (target, prop, value) => {{
251 try {{
252 Object.defineProperty(target, prop, {{
253 get: () => value,
254 enumerable: true,
255 configurable: false,
256 }});
257 }} catch (_) {{}}
258 }};
259
260 // Remove the webdriver flag at both the prototype and instance levels.
261 // Some anti-bot checks (e.g. pixelscan) probe Navigator.prototype directly
262 // via Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver'), so
263 // we must patch both; configurable:true on the prototype is intentional —
264 // Navigator.prototype properties are configurable in Chrome and must remain
265 // so to avoid errors if any polyfill attempts a second defineProperty call.
266 try {{
267 Object.defineProperty(Navigator.prototype, 'webdriver', {{
268 get: () => undefined,
269 enumerable: true,
270 configurable: true,
271 }});
272 }} catch (_) {{}}
273 defineReadOnly(navigator, 'webdriver', undefined);
274
275 // Platform / identity
276 defineReadOnly(navigator, 'platform', {platform:?});
277 defineReadOnly(navigator, 'userAgent', {user_agent:?});
278 defineReadOnly(navigator, 'vendor', {vendor:?});
279 defineReadOnly(navigator, 'hardwareConcurrency', {hwc});
280 defineReadOnly(navigator, 'deviceMemory', {dm});
281 defineReadOnly(navigator, 'maxTouchPoints', {mtp});
282
283 // Permissions API — real browsers resolve 'notifications' as 'default'
284 if (navigator.permissions && navigator.permissions.query) {{
285 const origQuery = navigator.permissions.query.bind(navigator.permissions);
286 navigator.permissions.query = (params) => {{
287 if (params && params.name === 'notifications') {{
288 return Promise.resolve({{ state: Notification.permission, onchange: null }});
289 }}
290 return origQuery(params);
291 }};
292 }}
293 }})();",
294 platform = nav.platform,
295 user_agent = nav.user_agent,
296 vendor = nav.vendor,
297 hwc = nav.hardware_concurrency,
298 dm = nav.device_memory,
299 mtp = nav.max_touch_points,
300 )
301 }
302
303 fn webgl_spoof_script(&self) -> String {
304 let nav = &self.navigator;
305
306 format!(
307 r" // --- WebGL fingerprint spoofing ---
308 (function() {{
309 const GL_VENDOR = 0x1F00;
310 const GL_RENDERER = 0x1F01;
311
312 const spoofCtx = (ctx) => {{
313 if (!ctx) return;
314 const origGetParam = ctx.getParameter.bind(ctx);
315 ctx.getParameter = (param) => {{
316 if (param === GL_VENDOR) return {webgl_vendor:?};
317 if (param === GL_RENDERER) return {webgl_renderer:?};
318 return origGetParam(param);
319 }};
320 }};
321
322 // Wrap HTMLCanvasElement.prototype.getContext
323 const origGetContext = HTMLCanvasElement.prototype.getContext;
324 HTMLCanvasElement.prototype.getContext = function(type, ...args) {{
325 const ctx = origGetContext.call(this, type, ...args);
326 if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') {{
327 spoofCtx(ctx);
328 }}
329 return ctx;
330 }};
331 }})();",
332 webgl_vendor = nav.webgl_vendor,
333 webgl_renderer = nav.webgl_renderer,
334 )
335 }
336}
337
338pub async fn apply_stealth_to_page(
370 page: &chromiumoxide::Page,
371 config: &crate::config::BrowserConfig,
372) -> crate::error::Result<()> {
373 use crate::cdp_protection::CdpProtection;
374 use crate::config::StealthLevel;
375 use chromiumoxide::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
376
377 async fn inject_one(
379 page: &chromiumoxide::Page,
380 op: &'static str,
381 source: String,
382 ) -> crate::error::Result<()> {
383 use crate::error::BrowserError;
384 page.evaluate_on_new_document(AddScriptToEvaluateOnNewDocumentParams {
385 source,
386 world_name: None,
387 include_command_line_api: None,
388 run_immediately: None,
389 })
390 .await
391 .map_err(|e| BrowserError::CdpError {
392 operation: op.to_string(),
393 message: e.to_string(),
394 })?;
395 Ok(())
396 }
397
398 if config.stealth_level == StealthLevel::None {
399 return Ok(());
400 }
401
402 let cdp_script =
404 CdpProtection::new(config.cdp_fix_mode, config.source_url.clone()).build_injection_script();
405 if !cdp_script.is_empty() {
406 inject_one(page, "AddScriptToEvaluateOnNewDocument(cdp)", cdp_script).await?;
407 }
408
409 let (nav_profile, stealth_cfg) = match config.stealth_level {
410 StealthLevel::Basic => (NavigatorProfile::default(), StealthConfig::minimal()),
411 StealthLevel::Advanced => (
412 NavigatorProfile::windows_chrome(),
413 StealthConfig::paranoid(),
414 ),
415 StealthLevel::None => unreachable!(),
416 };
417 let nav_script = StealthProfile::new(stealth_cfg, nav_profile).injection_script();
418 if !nav_script.is_empty() {
419 inject_one(
420 page,
421 "AddScriptToEvaluateOnNewDocument(navigator)",
422 nav_script,
423 )
424 .await?;
425 }
426
427 if config.stealth_level == StealthLevel::Advanced {
429 let fp = crate::fingerprint::Fingerprint::random();
430 let fp_script = crate::fingerprint::inject_fingerprint(&fp);
431 inject_one(
432 page,
433 "AddScriptToEvaluateOnNewDocument(fingerprint)",
434 fp_script,
435 )
436 .await?;
437
438 let webrtc_script = config.webrtc.injection_script();
439 if !webrtc_script.is_empty() {
440 inject_one(
441 page,
442 "AddScriptToEvaluateOnNewDocument(webrtc)",
443 webrtc_script,
444 )
445 .await?;
446 }
447 }
448
449 Ok(())
450}
451
452#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn disabled_config_produces_empty_script() {
460 let p = StealthProfile::new(StealthConfig::disabled(), NavigatorProfile::default());
461 assert_eq!(p.injection_script(), "");
462 }
463
464 #[test]
465 fn navigator_script_contains_platform() {
466 let profile = NavigatorProfile::windows_chrome();
467 let p = StealthProfile::new(StealthConfig::minimal(), profile);
468 let script = p.injection_script();
469 assert!(script.contains("Win32"), "platform must be in script");
470 assert!(
471 script.contains("'webdriver'"),
472 "webdriver removal must be present"
473 );
474 }
475
476 #[test]
477 fn navigator_script_contains_user_agent() {
478 let p = StealthProfile::new(StealthConfig::minimal(), NavigatorProfile::mac_chrome());
479 let script = p.injection_script();
480 assert!(script.contains("Mac OS X"));
481 assert!(script.contains("MacIntel"));
482 }
483
484 #[test]
485 fn webgl_script_contains_vendor_renderer() {
486 let p = StealthProfile::new(
487 StealthConfig {
488 spoof_navigator: false,
489 randomize_webgl: true,
490 ..StealthConfig::disabled()
491 },
492 NavigatorProfile::windows_chrome(),
493 );
494 let script = p.injection_script();
495 assert!(
496 script.contains("NVIDIA"),
497 "WebGL vendor must appear in script"
498 );
499 assert!(
500 script.contains("getParameter"),
501 "WebGL method must be overridden"
502 );
503 }
504
505 #[test]
506 fn full_profile_wraps_in_iife() {
507 let p = StealthProfile::new(StealthConfig::default(), NavigatorProfile::default());
508 let script = p.injection_script();
509 assert!(script.starts_with("(function()"), "script must be an IIFE");
510 assert!(script.ends_with("})();"));
511 }
512
513 #[test]
514 fn navigator_profile_linux_has_correct_platform() {
515 assert_eq!(NavigatorProfile::linux_chrome().platform, "Linux x86_64");
516 }
517
518 #[test]
519 fn stealth_config_paranoid_equals_default() {
520 let a = StealthConfig::paranoid();
521 let b = StealthConfig::default();
522 assert_eq!(a.spoof_navigator, b.spoof_navigator);
523 assert_eq!(a.randomize_webgl, b.randomize_webgl);
524 assert_eq!(a.randomize_canvas, b.randomize_canvas);
525 assert_eq!(a.human_behavior, b.human_behavior);
526 assert_eq!(a.protect_cdp, b.protect_cdp);
527 }
528
529 #[test]
530 fn hardware_concurrency_reasonable() {
531 let p = NavigatorProfile::windows_chrome();
532 assert!(p.hardware_concurrency >= 2);
533 assert!(p.hardware_concurrency <= 64);
534 }
535
536 #[test]
539 fn none_level_is_not_active() {
540 use crate::config::StealthLevel;
541 assert!(!StealthLevel::None.is_active());
542 }
543
544 #[test]
545 fn basic_level_cdp_script_removes_webdriver() {
546 use crate::cdp_protection::{CdpFixMode, CdpProtection};
547 let script = CdpProtection::new(CdpFixMode::AddBinding, None).build_injection_script();
548 assert!(
549 script.contains("webdriver"),
550 "CDP protection script should remove navigator.webdriver"
551 );
552 }
553
554 #[test]
555 fn basic_level_minimal_config_injects_navigator() {
556 let config = StealthConfig::minimal();
557 let profile = NavigatorProfile::default();
558 let script = StealthProfile::new(config, profile).injection_script();
559 assert!(
560 !script.is_empty(),
561 "Basic stealth should produce a navigator script"
562 );
563 }
564
565 #[test]
566 fn advanced_level_paranoid_config_includes_webgl() {
567 let config = StealthConfig::paranoid();
568 let profile = NavigatorProfile::windows_chrome();
569 let script = StealthProfile::new(config, profile).injection_script();
570 assert!(
571 script.contains("webgl") && script.contains("getParameter"),
572 "Advanced stealth should spoof WebGL via getParameter patching"
573 );
574 }
575
576 #[test]
577 fn advanced_level_fingerprint_script_non_empty() {
578 use crate::fingerprint::{Fingerprint, inject_fingerprint};
579 let fp = Fingerprint::random();
580 let script = inject_fingerprint(&fp);
581 assert!(
582 !script.is_empty(),
583 "Fingerprint injection script must not be empty"
584 );
585 }
586
587 #[test]
588 fn stealth_level_ordering() {
589 use crate::config::StealthLevel;
590 assert!(!StealthLevel::None.is_active());
591 assert!(StealthLevel::Basic.is_active());
592 assert!(StealthLevel::Advanced.is_active());
593 }
594
595 #[test]
596 fn navigator_profile_basic_uses_default() {
597 let profile = NavigatorProfile::default();
599 assert_eq!(profile.platform, "MacIntel");
600 }
601
602 #[test]
603 fn navigator_profile_advanced_uses_windows() {
604 let profile = NavigatorProfile::windows_chrome();
605 assert_eq!(profile.platform, "Win32");
606 }
607}