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