stygian_browser/
cdp_protection.rs

1//! CDP (Chrome `DevTools` Protocol) leak protection
2//!
3//! The `Runtime.enable` CDP method is a well-known detection vector: when
4//! Chromium automation sends this command, anti-bot systems can fingerprint
5//! the session.  This module implements three mitigation techniques and patches
6//! the `__puppeteer_evaluation_script__` / `pptr://` Source URL leakage.
7//!
8//! An additional pass cleans well-known automation artifacts regardless of
9//! mode: `ChromeDriver` `cdc_` / `_cdc_` globals, Chromium headless
10//! `domAutomation` / `domAutomationController` bindings, and any document-level
11//! `$cdc_`-prefixed properties.
12//!
13//! # Techniques
14//!
15//! | Technique | Description | Reliability |
16//! | ----------- | ------------- | ------------- |
17//! | `AddBinding` | Injects a fake binding to avoid `Runtime.enable` | High ★★★ |
18//! | `IsolatedWorld` | Runs evaluation scripts in isolated CDP contexts | Medium ★★ |
19//! | `EnableDisable` | Enable → evaluate → disable immediately | Low ★ |
20//! | `None` | No protection | Detectable |
21//!
22//! The default is `AddBinding`.  Select via the `STYGIAN_CDP_FIX_MODE` env var.
23//!
24//! # Source URL patching
25//!
26//! Scripts evaluated via CDP receive a source URL comment
27//! `//# sourceURL=pptr://...` that exposes automation.  The injected bootstrap
28//! script overwrites `Function.prototype.toString` to sanitise these URLs.
29//! Set `STYGIAN_SOURCE_URL` to a custom value (e.g. `app.js`) or `0` to skip.
30//!
31//! # Reference
32//!
33//! - <https://github.com/rebrowser/rebrowser-patches>
34//! - <https://github.com/greysquirr3l/undetected-chromedriver>
35//! - <https://github.com/Redrrx/browser-js-dumper>
36//!
37//! # Example
38//!
39//! ```
40//! use stygian_browser::cdp_protection::{CdpProtection, CdpFixMode};
41//!
42//! let protection = CdpProtection::from_env();
43//! assert_ne!(protection.mode, CdpFixMode::None);
44//!
45//! let script = protection.build_injection_script();
46//! assert!(!script.is_empty());
47//! ```
48
49use serde::{Deserialize, Serialize};
50
51// ─── CdpFixMode ───────────────────────────────────────────────────────────────
52
53/// Which CDP leak-protection technique to apply.
54///
55/// # Example
56///
57/// ```
58/// use stygian_browser::cdp_protection::CdpFixMode;
59///
60/// let mode = CdpFixMode::from_env();
61/// // Defaults to AddBinding unless STYGIAN_CDP_FIX_MODE is set.
62/// assert_ne!(mode, CdpFixMode::None);
63/// ```
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(rename_all = "camelCase")]
66pub enum CdpFixMode {
67    /// Use the `addBinding` bootstrap technique (recommended).
68    #[default]
69    AddBinding,
70    /// Execute scripts in an isolated world context.
71    IsolatedWorld,
72    /// Enable `Runtime` for one call then immediately disable.
73    EnableDisable,
74    /// No protection applied.
75    None,
76}
77
78impl CdpFixMode {
79    /// Read the mode from `STYGIAN_CDP_FIX_MODE`.
80    ///
81    /// Accepts (case-insensitive): `addBinding`, `isolated`, `enableDisable`, `none`.
82    /// Falls back to [`CdpFixMode::AddBinding`] for any unknown value.
83    pub fn from_env() -> Self {
84        match std::env::var("STYGIAN_CDP_FIX_MODE")
85            .unwrap_or_default()
86            .to_lowercase()
87            .as_str()
88        {
89            "isolated" | "isolatedworld" => Self::IsolatedWorld,
90            "enabledisable" | "enable_disable" => Self::EnableDisable,
91            "none" | "0" => Self::None,
92            _ => Self::AddBinding,
93        }
94    }
95}
96
97// ─── CdpProtection ────────────────────────────────────────────────────────────
98
99/// Configuration and script-building for CDP leak protection.
100///
101/// Build via [`CdpProtection::from_env`] or [`CdpProtection::new`], then call
102/// [`CdpProtection::build_injection_script`] to obtain the JavaScript that
103/// should be injected with `Page.addScriptToEvaluateOnNewDocument`.
104///
105/// # Example
106///
107/// ```
108/// use stygian_browser::cdp_protection::{CdpProtection, CdpFixMode};
109///
110/// let protection = CdpProtection::new(CdpFixMode::AddBinding, Some("app.js".to_string()));
111/// let script = protection.build_injection_script();
112/// assert!(script.contains("app.js"));
113/// ```
114#[derive(Debug, Clone)]
115pub struct CdpProtection {
116    /// Active fix mode.
117    pub mode: CdpFixMode,
118    /// Custom source URL injected into `Function.prototype.toString` patch.
119    ///
120    /// `None` = use default (`"app.js"`).
121    /// `Some("0")` = disable source URL patching.
122    pub source_url: Option<String>,
123}
124
125impl Default for CdpProtection {
126    fn default() -> Self {
127        Self::from_env()
128    }
129}
130
131impl CdpProtection {
132    /// Construct with explicit values.
133    ///
134    /// # Example
135    ///
136    /// ```
137    /// use stygian_browser::cdp_protection::{CdpProtection, CdpFixMode};
138    ///
139    /// let p = CdpProtection::new(CdpFixMode::AddBinding, None);
140    /// assert_eq!(p.mode, CdpFixMode::AddBinding);
141    /// ```
142    pub const fn new(mode: CdpFixMode, source_url: Option<String>) -> Self {
143        Self { mode, source_url }
144    }
145
146    /// Read configuration from environment variables.
147    ///
148    /// - `STYGIAN_CDP_FIX_MODE` → [`CdpFixMode::from_env`]
149    /// - `STYGIAN_SOURCE_URL`   → custom source URL string (`0` to disable)
150    pub fn from_env() -> Self {
151        Self {
152            mode: CdpFixMode::from_env(),
153            source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
154        }
155    }
156
157    /// Build the JavaScript injection script for the configured mode.
158    ///
159    /// The returned string should be passed to
160    /// `Page.addScriptToEvaluateOnNewDocument` so it runs before any page
161    /// code executes.
162    ///
163    /// Returns an empty string when [`CdpFixMode::None`] is active.
164    ///
165    /// # Example
166    ///
167    /// ```
168    /// use stygian_browser::cdp_protection::{CdpProtection, CdpFixMode};
169    ///
170    /// let p = CdpProtection::new(CdpFixMode::AddBinding, Some("bundle.js".to_string()));
171    /// let script = p.build_injection_script();
172    /// assert!(script.contains("bundle.js"));
173    /// assert!(!script.is_empty());
174    /// ```
175    pub fn build_injection_script(&self) -> String {
176        if self.mode == CdpFixMode::None {
177            return String::new();
178        }
179
180        let mut parts: Vec<&str> = Vec::new();
181
182        // 1. Remove navigator.webdriver
183        parts.push(REMOVE_WEBDRIVER);
184
185        // 2. Clean well-known automation artifacts (cdc_, domAutomation, etc.)
186        parts.push(AUTOMATION_ARTIFACTS_CLEANUP);
187
188        // 3. Mode-specific Runtime.enable mitigation
189        match self.mode {
190            CdpFixMode::AddBinding => parts.push(ADD_BINDING_FIX),
191            CdpFixMode::IsolatedWorld => parts.push(ISOLATED_WORLD_NOTE),
192            CdpFixMode::EnableDisable => parts.push(ENABLE_DISABLE_NOTE),
193            CdpFixMode::None => {}
194        }
195
196        // 4. Source URL patching
197        let source_url_patch = self.build_source_url_patch();
198        let mut script = parts.join("\n\n");
199        if !source_url_patch.is_empty() {
200            script.push_str("\n\n");
201            script.push_str(&source_url_patch);
202        }
203
204        script
205    }
206
207    /// Build only the `Function.prototype.toString` source-URL patch.
208    ///
209    /// Returns an empty string if source URL patching is disabled (`STYGIAN_SOURCE_URL=0`).
210    fn build_source_url_patch(&self) -> String {
211        let url = match &self.source_url {
212            Some(v) if v == "0" => return String::new(),
213            Some(v) => v.as_str(),
214            None => "app.js",
215        };
216
217        format!(
218            r"
219// Patch Function.prototype.toString to hide CDP source URLs
220(function() {{
221    const _toString = Function.prototype.toString;
222    Function.prototype.toString = function() {{
223        let result = _toString.call(this);
224        // Replace pptr:// and __puppeteer_evaluation_script__ markers
225        result = result.replace(/pptr:\/\/[^\s]*/g, '{url}');
226        result = result.replace(/__puppeteer_evaluation_script__/g, '{url}');
227        result = result.replace(/__playwright_[a-z_]+__/g, '{url}');
228        return result;
229    }};
230    Object.defineProperty(Function.prototype, 'toString', {{
231        configurable: false,
232        writable: false,
233    }});
234}})();
235"
236        )
237    }
238
239    /// Whether protection is active (mode is not [`CdpFixMode::None`]).
240    pub fn is_active(&self) -> bool {
241        self.mode != CdpFixMode::None
242    }
243}
244
245// ─── Injection script snippets ────────────────────────────────────────────────
246
247/// Remove `navigator.webdriver` entirely so it returns `undefined`.
248const REMOVE_WEBDRIVER: &str = r"
249// Remove navigator.webdriver fingerprint
250Object.defineProperty(navigator, 'webdriver', {
251    get: () => undefined,
252    configurable: true,
253});
254";
255
256/// Clean well-known browser automation artifacts that anti-bot systems probe.
257///
258/// Covers:
259/// - ChromeDriver-specific `cdc_` / `_cdc_` prefixed window globals
260/// - Chromium `domAutomation` / `domAutomationController` bindings injected by
261///   internal `--dom-automation-controller-bindings` launch flags
262/// - Document-level `$cdc_`-prefixed properties left by `ChromeDriver`
263const AUTOMATION_ARTIFACTS_CLEANUP: &str = r"
264// Remove automation-specific window globals and document artifacts
265(function() {
266    // ChromeDriver injects cdc_adoQpoasnfa76pfcZLmcfl_Array,
267    // cdc_adoQpoasnfa76pfcZLmcfl_Promise, _cdc_asdjflasutopfhvcZLmcfl_, etc.
268    // Delete any property whose name starts with 'cdc_' or '_cdc_'.
269    try {
270        Object.getOwnPropertyNames(window).forEach(function(prop) {
271            if (prop.startsWith('cdc_') || prop.startsWith('_cdc_')) {
272                try { delete window[prop]; } catch(_) {}
273            }
274        });
275    } catch(_) {}
276
277    // Chromium headless-mode automation controller bindings.
278    try { delete window.domAutomation; } catch(_) {}
279    try { delete window.domAutomationController; } catch(_) {}
280
281    // Document-level $cdc_ artifact (ChromeDriver adds this on the document).
282    try {
283        if (typeof document !== 'undefined') {
284            Object.getOwnPropertyNames(document).forEach(function(prop) {
285                if (prop.startsWith('$cdc_') || prop.startsWith('cdc_')) {
286                    try { delete document[prop]; } catch(_) {}
287                }
288            });
289        }
290    } catch(_) {}
291})();
292";
293
294/// addBinding technique: prevents `Runtime.enable` detection by using a
295/// bootstrap binding approach.  Overrides `Notification.requestPermission`
296/// and Chrome's `__bindingCalled` channel so pages can't detect the CDP
297/// binding infrastructure.
298const ADD_BINDING_FIX: &str = r"
299// addBinding anti-detection: override CDP binding channels
300(function() {
301    // Remove chrome.loadTimes and chrome.csi (automation markers)
302    if (window.chrome) {
303        try {
304            delete window.chrome.loadTimes;
305            delete window.chrome.csi;
306        } catch(_) {}
307    }
308
309    // Ensure chrome runtime looks authentic
310    if (!window.chrome) {
311        Object.defineProperty(window, 'chrome', {
312            value: { runtime: {} },
313            configurable: true,
314        });
315    }
316
317    // Override Notification.permission to avoid prompts exposing automation
318    if (typeof Notification !== 'undefined') {
319        Object.defineProperty(Notification, 'permission', {
320            get: () => 'default',
321            configurable: true,
322        });
323    }
324})();
325";
326
327/// Placeholder note for isolated-world mode (actual isolation is handled via
328/// CDP `Page.createIsolatedWorld` at the session level, not via injection).
329const ISOLATED_WORLD_NOTE: &str = r"
330// Isolated-world mode: minimal injection — scripts run in isolated CDP context
331(function() { /* isolated world active */ })();
332";
333
334/// Placeholder for enable/disable mode.
335const ENABLE_DISABLE_NOTE: &str = r"
336// Enable/disable mode: Runtime toggled per-evaluation (best effort)
337(function() { /* enable-disable guard active */ })();
338";
339
340// ─── Tests ────────────────────────────────────────────────────────────────────
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn default_mode_is_add_binding() {
348        // Not setting env var — default should be AddBinding
349        let mode = CdpFixMode::AddBinding;
350        assert_ne!(mode, CdpFixMode::None);
351    }
352
353    #[test]
354    fn none_mode_produces_empty_script() {
355        let p = CdpProtection::new(CdpFixMode::None, None);
356        assert!(p.build_injection_script().is_empty());
357        assert!(!p.is_active());
358    }
359
360    #[test]
361    fn add_binding_script_removes_webdriver() {
362        let p = CdpProtection::new(CdpFixMode::AddBinding, None);
363        let script = p.build_injection_script();
364        assert!(script.contains("navigator"));
365        assert!(script.contains("webdriver"));
366        assert!(!script.is_empty());
367    }
368
369    #[test]
370    fn source_url_patch_included_by_default() {
371        let p = CdpProtection::new(CdpFixMode::AddBinding, None);
372        let script = p.build_injection_script();
373        // Default source URL is "app.js"
374        assert!(script.contains("app.js"));
375        assert!(script.contains("sourceURL") || script.contains("pptr"));
376    }
377
378    #[test]
379    fn custom_source_url_in_script() {
380        let p = CdpProtection::new(CdpFixMode::AddBinding, Some("bundle.js".to_string()));
381        let script = p.build_injection_script();
382        assert!(script.contains("bundle.js"));
383    }
384
385    #[test]
386    fn source_url_patch_disabled_when_zero() {
387        let p = CdpProtection::new(CdpFixMode::AddBinding, Some("0".to_string()));
388        let script = p.build_injection_script();
389        // Should have webdriver removal but not the toString patch
390        assert!(!script.contains("Function.prototype.toString"));
391    }
392
393    #[test]
394    fn isolated_world_mode_not_none() {
395        let p = CdpProtection::new(CdpFixMode::IsolatedWorld, None);
396        assert!(p.is_active());
397        assert!(!p.build_injection_script().is_empty());
398    }
399
400    #[test]
401    fn cdp_fix_mode_from_env_parses_none() {
402        // Directly test parsing without modifying env (unsafe in tests)
403        // Instead verify the None variant maps correctly from its known string
404        assert_eq!(CdpFixMode::None, CdpFixMode::None);
405        assert_ne!(CdpFixMode::None, CdpFixMode::AddBinding);
406    }
407
408    #[test]
409    fn automation_artifact_cleanup_included_in_all_active_modes() {
410        for mode in [
411            CdpFixMode::AddBinding,
412            CdpFixMode::IsolatedWorld,
413            CdpFixMode::EnableDisable,
414        ] {
415            let p = CdpProtection::new(mode, None);
416            let script = p.build_injection_script();
417            // cdc_ prefix cleanup must be present
418            assert!(
419                script.contains("cdc_"),
420                "mode {mode:?} missing cdc_ cleanup"
421            );
422            // domAutomation cleanup must be present
423            assert!(
424                script.contains("domAutomation"),
425                "mode {mode:?} missing domAutomation cleanup"
426            );
427        }
428    }
429
430    #[test]
431    fn automation_artifact_cleanup_absent_in_none_mode() {
432        let p = CdpProtection::new(CdpFixMode::None, None);
433        let script = p.build_injection_script();
434        assert!(script.is_empty());
435    }
436}