Skip to main content

stygian_browser/
cdp_hardening.rs

1//! Advanced CDP leak hardening.
2//!
3//! Injects a script that runs **before** all other stealth scripts to:
4//!
5//! 1. Delete Playwright / Puppeteer binding remnants from `window`.
6//! 2. Sanitize `Error.prototype.stack` to remove CDP-specific frame URLs.
7//! 3. Harden `console.debug` so a getter-based stack-inspection trap cannot
8//!    detect CDP from within it.
9//! 4. Ensure the `Navigator.prototype.webdriver` property descriptor matches
10//!    Chrome's native format (accessor descriptor with a getter, no setter).
11//! 5. Mark as non-enumerable any artifact properties that CDP injection may
12//!    have left enumerable on `window`.
13//!
14//! # Example
15//!
16//! ```
17//! use stygian_browser::cdp_hardening::{cdp_hardening_script, CdpHardeningConfig};
18//!
19//! let cfg = CdpHardeningConfig::default();
20//! let js = cdp_hardening_script(&cfg);
21//! assert!(js.contains("__playwright"));
22//! assert!(js.contains("Error.prototype"));
23//! ```
24
25use serde::{Deserialize, Serialize};
26
27// ---------------------------------------------------------------------------
28// Config
29// ---------------------------------------------------------------------------
30
31/// Configuration for CDP leak hardening.
32///
33/// # Example
34///
35/// ```
36/// use stygian_browser::cdp_hardening::CdpHardeningConfig;
37///
38/// let cfg = CdpHardeningConfig::default();
39/// assert!(cfg.enabled);
40/// assert!(cfg.sanitize_stacks);
41/// assert!(cfg.protect_console);
42/// ```
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CdpHardeningConfig {
45    /// Master switch. When `false`, [`cdp_hardening_script`] returns an empty string.
46    pub enabled: bool,
47    /// Whether to sanitize CDP-specific frames from `Error.prototype.stack`.
48    pub sanitize_stacks: bool,
49    /// Whether to override `console.debug` to defeat getter-trap-based stack inspection.
50    pub protect_console: bool,
51}
52
53impl Default for CdpHardeningConfig {
54    /// All protections enabled.
55    fn default() -> Self {
56        Self {
57            enabled: true,
58            sanitize_stacks: true,
59            protect_console: true,
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Script generator
66// ---------------------------------------------------------------------------
67
68/// Generate the CDP hardening injection script.
69///
70/// Returns an empty string when `config.enabled` is `false`.
71///
72/// # Example
73///
74/// ```
75/// use stygian_browser::cdp_hardening::{cdp_hardening_script, CdpHardeningConfig};
76///
77/// let js = cdp_hardening_script(&CdpHardeningConfig { enabled: false, ..Default::default() });
78/// assert!(js.is_empty());
79///
80/// let js2 = cdp_hardening_script(&CdpHardeningConfig::default());
81/// assert!(js2.contains("__playwright__binding__"));
82/// ```
83#[must_use]
84#[allow(clippy::too_many_lines)]
85pub fn cdp_hardening_script(config: &CdpHardeningConfig) -> String {
86    if !config.enabled {
87        return String::new();
88    }
89
90    let stack_section = if config.sanitize_stacks {
91        ERROR_STACK_SECTION
92    } else {
93        ""
94    };
95
96    let console_section = if config.protect_console {
97        CONSOLE_DEBUG_SECTION
98    } else {
99        ""
100    };
101
102    format!(
103        r"(function() {{
104  'use strict';
105
106  // ── 1. Delete Playwright / Puppeteer binding remnants ─────────────────
107  var _cdpArtifacts = [
108    '__playwright__binding__',
109    '__pwInitScripts',
110    '__playwright_evaluation_script__',
111    '__puppeteer_evaluation_script__',
112    '__puppeteer__binding__',
113    '__playwright_clock__',
114    '__pw_manual_fulfill__',
115    '__pw_dispatch_event__',
116    '__pwpEventListeners',
117  ];
118  _cdpArtifacts.forEach(function(key) {{
119    try {{ delete window[key]; }} catch(e) {{}}
120    try {{
121      if (key in window) {{
122        Object.defineProperty(window, key, {{
123          value: undefined, writable: false, configurable: false, enumerable: false
124        }});
125      }}
126    }} catch(e) {{}}
127  }});
128
129{stack_section}
130{console_section}
131
132  // ── 4. Navigator.prototype.webdriver — native-looking accessor descriptor ──
133  try {{
134    // Chrome's native descriptor: {{ get: f, set: undefined, enumerable: true, configurable: true }}
135    var _wdDesc = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
136    if (_wdDesc) {{
137      // If currently a data descriptor or has a set, redefine as accessor-only
138      if (!_wdDesc.get || _wdDesc.set !== undefined) {{
139        var _wdGetter = function webdriver() {{ return false; }};
140        _wdGetter.toString = function toString() {{
141          return 'function webdriver() {{ [native code] }}';
142        }};
143        Object.defineProperty(Navigator.prototype, 'webdriver', {{
144          get: _wdGetter,
145          set: undefined,
146          enumerable: true,
147          configurable: true,
148        }});
149      }} else {{
150        // Patch the existing getter to return false
151        var _existingGetter = _wdDesc.get;
152        // Only override if current getter would reveal webdriver=true
153        void _existingGetter; // referenced intentionally
154        var _falseGetter = function webdriver() {{ return false; }};
155        _falseGetter.toString = function toString() {{
156          return 'function webdriver() {{ [native code] }}';
157        }};
158        Object.defineProperty(Navigator.prototype, 'webdriver', {{
159          get: _falseGetter,
160          set: undefined,
161          enumerable: true,
162          configurable: true,
163        }});
164      }}
165    }}
166  }} catch(e) {{}}
167
168  // ── 5. Enumeration protection — mark CDP artifacts as non-enumerable ──
169  var _nonEnumProps = [
170    'cdc_adoQpoasnfa76pfcZLmcfl_Array',
171    'cdc_adoQpoasnfa76pfcZLmcfl_Promise',
172    'cdc_adoQpoasnfa76pfcZLmcfl_Symbol',
173    '__cdc_asdjflasutopfhvcZLmcfl_',
174    '__selenium_evaluate',
175    '__selenium_unwrapped',
176    '__webdriverFunc',
177    '__webdriver_evaluate',
178    '__driver_evaluate',
179    '__driver_unwrapped',
180    '__lastWatirAlert',
181    '__lastWatirConfirm',
182    '__lastWatirPrompt',
183  ];
184  _nonEnumProps.forEach(function(key) {{
185    try {{
186      if (key in window) {{
187        var _desc = Object.getOwnPropertyDescriptor(window, key);
188        if (_desc && _desc.enumerable) {{
189          Object.defineProperty(window, key, {{
190            value: _desc.value,
191            writable: _desc.writable || false,
192            configurable: _desc.configurable || false,
193            enumerable: false,
194          }});
195        }}
196      }}
197    }} catch(e) {{}}
198  }});
199
200}})();
201",
202    )
203}
204
205// ── Static script sections ────────────────────────────────────────────────
206
207/// Error.prototype.stack sanitization section.
208const ERROR_STACK_SECTION: &str = r"  // ── 2. Sanitize Error.prototype.stack ───────────────────────────────
209  // Detects and removes frames from: __puppeteer, __playwright, pptr:, puppeteer-eval,
210  // playwright-eval, and chrome-extension:// URLs.
211  try {
212    var _origStackDesc = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
213    if (_origStackDesc && _origStackDesc.get) {
214      var _origStackGetter = _origStackDesc.get;
215      var _cdpFrameRe = /(https?:\/\/[^\s]*(?:__puppeteer|__playwright|pptr:|puppeteer-eval|playwright-eval)[^\s]*|chrome-extension:\/\/[^\s]*)/g;
216      var _sanitizedGetter = function stack() {
217        var s = _origStackGetter.call(this);
218        if (typeof s !== 'string') { return s; }
219        return s.replace(_cdpFrameRe, 'https://example.com/app.js');
220      };
221      _sanitizedGetter.toString = function toString() {
222        return 'function get stack() { [native code] }';
223      };
224      Object.defineProperty(Error.prototype, 'stack', {
225        get: _sanitizedGetter,
226        set: _origStackDesc.set,
227        enumerable: _origStackDesc.enumerable,
228        configurable: _origStackDesc.configurable,
229      });
230    } else if (_origStackDesc && 'value' in _origStackDesc) {
231      // Data-descriptor path: wrap with a getter going forward
232      // Nothing to patch here at definition time; stack is per-instance
233    }
234  } catch(e) {}";
235
236/// console.debug protection section.
237const CONSOLE_DEBUG_SECTION: &str = r"  // ── 3. console.debug getter-trap hardening ────────────────────────────
238  try {
239    var _origDebug = console.debug.bind(console);
240    var _safeDebug = function debug() {
241      return _origDebug.apply(console, arguments);
242    };
243    _safeDebug.toString = function toString() {
244      return 'function debug() { [native code] }';
245    };
246    try {
247      console.debug = _safeDebug;
248    } catch(e) {
249      Object.defineProperty(console, 'debug', {
250        value: _safeDebug, writable: true, configurable: true, enumerable: false
251      });
252    }
253  } catch(e) {}";
254
255// ---------------------------------------------------------------------------
256// Tests
257// ---------------------------------------------------------------------------
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn default_script() -> String {
264        cdp_hardening_script(&CdpHardeningConfig::default())
265    }
266
267    #[test]
268    fn disabled_returns_empty() {
269        let js = cdp_hardening_script(&CdpHardeningConfig {
270            enabled: false,
271            ..Default::default()
272        });
273        assert!(js.is_empty());
274    }
275
276    #[test]
277    fn script_deletes_playwright_artifacts() {
278        let js = default_script();
279        assert!(
280            js.contains("__playwright__binding__"),
281            "missing playwright binding"
282        );
283        assert!(js.contains("__pwInitScripts"), "missing pwInitScripts");
284        assert!(
285            js.contains("__playwright_evaluation_script__"),
286            "missing playwright eval script"
287        );
288        assert!(
289            js.contains("__puppeteer_evaluation_script__"),
290            "missing puppeteer eval script"
291        );
292    }
293
294    #[test]
295    fn error_stack_sanitizer_regex_present() {
296        let js = default_script();
297        assert!(js.contains("__puppeteer"), "missing puppeteer pattern");
298        assert!(js.contains("__playwright"), "missing playwright pattern");
299        assert!(js.contains("pptr:"), "missing pptr: pattern");
300        assert!(
301            js.contains("chrome-extension://"),
302            "missing chrome-extension pattern"
303        );
304    }
305
306    #[test]
307    fn console_debug_has_native_tostring_spoof() {
308        let js = default_script();
309        assert!(
310            js.contains("function debug() { [native code] }"),
311            "missing native toString spoof for console.debug"
312        );
313    }
314
315    #[test]
316    fn webdriver_descriptor_matches_chrome_native() {
317        let js = default_script();
318        // Should define as accessor-only (no set)
319        assert!(
320            js.contains("set: undefined"),
321            "missing set: undefined for webdriver"
322        );
323        assert!(
324            js.contains("enumerable: true"),
325            "webdriver must be enumerable"
326        );
327        assert!(
328            js.contains("configurable: true"),
329            "webdriver must be configurable"
330        );
331    }
332
333    #[test]
334    fn no_new_enumerable_window_properties() {
335        let js = default_script();
336        // All artifact keys in section 5 should be marked non-enumerable
337        assert!(
338            js.contains("enumerable: false"),
339            "artifacts must be set non-enumerable"
340        );
341    }
342
343    #[test]
344    fn sanitize_stacks_false_omits_error_section() {
345        let js = cdp_hardening_script(&CdpHardeningConfig {
346            enabled: true,
347            sanitize_stacks: false,
348            protect_console: true,
349        });
350        assert!(
351            !js.contains("Error.prototype"),
352            "error section should be absent"
353        );
354        // console section should still be present
355        assert!(js.contains("console.debug"));
356    }
357
358    #[test]
359    fn protect_console_false_omits_console_section() {
360        let js = cdp_hardening_script(&CdpHardeningConfig {
361            enabled: true,
362            sanitize_stacks: true,
363            protect_console: false,
364        });
365        // No console.debug override
366        assert!(
367            !js.contains("_safeDebug"),
368            "console section should be absent"
369        );
370        // Error stack section still present
371        assert!(js.contains("Error.prototype"));
372    }
373}