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}