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}