stygian_browser/
stealth.rs

1//! Stealth configuration and anti-detection features
2//!
3//! Provides navigator property spoofing and CDP injection scripts that make
4//! a headless Chrome instance appear identical to a real browser.
5//!
6//! # Overview
7//!
8//! 1. **Navigator spoofing** — Override `navigator.webdriver`, `platform`,
9//!    `userAgent`, `hardwareConcurrency`, `deviceMemory`, `maxTouchPoints`,
10//!    and `vendor` via `Object.defineProperty` so properties are non-configurable
11//!    and non-writable (harder to detect the override).
12//!
13//! 2. **WebGL spoofing** — Replace `getParameter` on `WebGLRenderingContext` and
14//!    `WebGL2RenderingContext` to return controlled vendor/renderer strings.
15//!
16//! # Example
17//!
18//! ```
19//! use stygian_browser::stealth::{NavigatorProfile, StealthConfig, StealthProfile};
20//!
21//! let profile = NavigatorProfile::windows_chrome();
22//! let script = StealthProfile::new(StealthConfig::default(), profile).injection_script();
23//! assert!(script.contains("'webdriver'"));
24//! ```
25
26use serde::{Deserialize, Serialize};
27
28// ─── StealthConfig ────────────────────────────────────────────────────────────
29
30/// Feature flags controlling which stealth techniques are active.
31///
32/// # Example
33///
34/// ```
35/// use stygian_browser::stealth::StealthConfig;
36/// let cfg = StealthConfig::default();
37/// assert!(cfg.spoof_navigator);
38/// ```
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct StealthConfig {
41    /// Override navigator properties (webdriver, platform, userAgent, etc.)
42    pub spoof_navigator: bool,
43    /// Replace WebGL getParameter with controlled vendor/renderer strings.
44    pub randomize_webgl: bool,
45    /// Randomise Canvas `toDataURL` fingerprint (stub — needs canvas noise).
46    pub randomize_canvas: bool,
47    /// Enable human-like behaviour simulation.
48    pub human_behavior: bool,
49    /// Enable CDP leak protection (remove `Runtime.enable` artifacts).
50    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    /// All stealth features enabled (maximum evasion).
67    pub fn paranoid() -> Self {
68        Self::default()
69    }
70
71    /// Only navigator and CDP protection (low overhead).
72    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    /// All stealth features disabled.
83    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// ─── NavigatorProfile ─────────────────────────────────────────────────────────
95
96/// A bundle of navigator property values that together form a convincing
97/// browser identity.
98///
99/// All properties are validated at construction time to guarantee that
100/// `platform` matches the OS fragment in `user_agent`.
101///
102/// # Example
103///
104/// ```
105/// use stygian_browser::stealth::NavigatorProfile;
106/// let p = NavigatorProfile::mac_chrome();
107/// assert_eq!(p.platform, "MacIntel");
108/// assert!(p.user_agent.contains("Mac OS X"));
109/// ```
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct NavigatorProfile {
112    /// Full `User-Agent` string (`navigator.userAgent` **and** the HTTP header).
113    pub user_agent: String,
114    /// Platform string e.g. `"Win32"`, `"MacIntel"`, `"Linux x86_64"`.
115    pub platform: String,
116    /// Browser vendor (`"Google Inc."`).
117    pub vendor: String,
118    /// Logical CPU core count. Realistic values: 4, 8, 12, 16.
119    pub hardware_concurrency: u8,
120    /// Device RAM in GiB. Realistic values: 4, 8, 16.
121    pub device_memory: u8,
122    /// Maximum simultaneous touch points (0 = no touchscreen, 10 = tablet/phone).
123    pub max_touch_points: u8,
124    /// WebGL vendor string (only used when `StealthConfig::randomize_webgl` is true).
125    pub webgl_vendor: String,
126    /// WebGL renderer string.
127    pub webgl_renderer: String,
128}
129
130impl NavigatorProfile {
131    /// A typical Windows 10 Chrome 120 profile.
132    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    /// A typical macOS Chrome 120 profile.
150    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    /// A typical Linux Chrome profile (common in data-centre environments).
166    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
188// ─── StealthProfile ───────────────────────────────────────────────────────────
189
190/// Combines [`StealthConfig`] (feature flags) with a [`NavigatorProfile`]
191/// (identity values) and produces a single JavaScript injection script.
192///
193/// # Example
194///
195/// ```
196/// use stygian_browser::stealth::{NavigatorProfile, StealthConfig, StealthProfile};
197///
198/// let profile = StealthProfile::new(StealthConfig::default(), NavigatorProfile::windows_chrome());
199/// let script = profile.injection_script();
200/// assert!(script.contains("Win32"));
201/// assert!(script.contains("NVIDIA"));
202/// ```
203pub struct StealthProfile {
204    config: StealthConfig,
205    navigator: NavigatorProfile,
206}
207
208impl StealthProfile {
209    /// Build a new profile from config flags and identity values.
210    pub const fn new(config: StealthConfig, navigator: NavigatorProfile) -> Self {
211        Self { config, navigator }
212    }
213
214    /// Generate the JavaScript to inject via
215    /// `Page.addScriptToEvaluateOnNewDocument`.
216    ///
217    /// Returns an empty string if all stealth flags are disabled.
218    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        // Wrap in an IIFE so nothing leaks to page scope
234        format!(
235            "(function() {{\n  'use strict';\n{}\n}})();",
236            parts.join("\n\n")
237        )
238    }
239
240    // ─── Private helpers ──────────────────────────────────────────────────────
241
242    fn navigator_spoof_script(&self) -> String {
243        let nav = &self.navigator;
244
245        // Helper: Object.defineProperty with a fixed non-configurable value
246        // so the spoofed value cannot be overwritten by anti-bot scripts.
247        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
338// ─── Stealth application ──────────────────────────────────────────────────────
339
340/// Inject all stealth scripts into a freshly opened browser page.
341///
342/// Scripts are registered with `Page.addScriptToEvaluateOnNewDocument` so they
343/// execute before any page-owned JavaScript on every subsequent navigation.
344/// Which scripts are injected is determined by
345/// [`crate::config::StealthLevel`]:
346///
347/// | Level      | Injected content                                                        |
348/// | ------------ | ------------------------------------------------------------------------- |
349/// | `None`     | Nothing                                                                 |
350/// | `Basic`    | CDP leak fix + `navigator.webdriver` removal + minimal navigator spoof  |
351/// | `Advanced` | Basic + full WebGL/navigator spoofing + fingerprint + WebRTC protection |
352///
353/// # Errors
354///
355/// Returns [`crate::error::BrowserError::CdpError`] if a CDP command fails.
356///
357/// # Example
358///
359/// ```no_run
360/// # async fn run(
361/// #     page: &chromiumoxide::Page,
362/// #     config: &stygian_browser::BrowserConfig,
363/// # ) -> stygian_browser::Result<()> {
364/// use stygian_browser::stealth::apply_stealth_to_page;
365/// apply_stealth_to_page(page, config).await?;
366/// # Ok(())
367/// # }
368/// ```
369pub 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    /// Inline helper: push one script as `AddScriptToEvaluateOnNewDocument`.
378    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    // ── Basic and above ────────────────────────────────────────────────────────
403    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    // ── Advanced only ──────────────────────────────────────────────────────────
428    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// ─── Tests ────────────────────────────────────────────────────────────────────
453
454#[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    // ── T13: stealth level script generation ──────────────────────────────────
537
538    #[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        // Basic → default navigator profile (mac_chrome)
598        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}