Skip to main content

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