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