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}