use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum CdpFixMode {
#[default]
AddBinding,
IsolatedWorld,
EnableDisable,
None,
}
impl CdpFixMode {
pub fn from_env() -> Self {
match std::env::var("STYGIAN_CDP_FIX_MODE")
.unwrap_or_default()
.to_lowercase()
.as_str()
{
"isolated" | "isolatedworld" => Self::IsolatedWorld,
"enabledisable" | "enable_disable" => Self::EnableDisable,
"none" | "0" => Self::None,
_ => Self::AddBinding,
}
}
}
#[derive(Debug, Clone)]
pub struct CdpProtection {
pub mode: CdpFixMode,
pub source_url: Option<String>,
}
impl Default for CdpProtection {
fn default() -> Self {
Self::from_env()
}
}
impl CdpProtection {
pub const fn new(mode: CdpFixMode, source_url: Option<String>) -> Self {
Self { mode, source_url }
}
pub fn from_env() -> Self {
Self {
mode: CdpFixMode::from_env(),
source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
}
}
pub fn build_injection_script(&self) -> String {
if self.mode == CdpFixMode::None {
return String::new();
}
let mut parts: Vec<&str> = Vec::new();
parts.push(REMOVE_WEBDRIVER);
parts.push(AUTOMATION_ARTIFACTS_CLEANUP);
match self.mode {
CdpFixMode::AddBinding => parts.push(ADD_BINDING_FIX),
CdpFixMode::IsolatedWorld => parts.push(ISOLATED_WORLD_NOTE),
CdpFixMode::EnableDisable => parts.push(ENABLE_DISABLE_NOTE),
CdpFixMode::None => {}
}
let source_url_patch = self.build_source_url_patch();
let mut script = parts.join("\n\n");
if !source_url_patch.is_empty() {
script.push_str("\n\n");
script.push_str(&source_url_patch);
}
script
}
fn build_source_url_patch(&self) -> String {
let url = match &self.source_url {
Some(v) if v == "0" => return String::new(),
Some(v) => v.as_str(),
None => "app.js",
};
format!(
r"
// Patch Function.prototype.toString to hide CDP source URLs
(function() {{
const _toString = Function.prototype.toString;
Function.prototype.toString = function() {{
let result = _toString.call(this);
// Replace pptr:// and __puppeteer_evaluation_script__ markers
result = result.replace(/pptr:\/\/[^\s]*/g, '{url}');
result = result.replace(/__puppeteer_evaluation_script__/g, '{url}');
result = result.replace(/__playwright_[a-z_]+__/g, '{url}');
return result;
}};
Object.defineProperty(Function.prototype, 'toString', {{
configurable: false,
writable: false,
}});
}})();
"
)
}
pub fn is_active(&self) -> bool {
self.mode != CdpFixMode::None
}
}
const REMOVE_WEBDRIVER: &str = r"
// Remove navigator.webdriver fingerprint
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined,
configurable: true,
});
";
const AUTOMATION_ARTIFACTS_CLEANUP: &str = r"
// Remove automation-specific window globals and document artifacts
(function() {
// ChromeDriver injects cdc_adoQpoasnfa76pfcZLmcfl_Array,
// cdc_adoQpoasnfa76pfcZLmcfl_Promise, _cdc_asdjflasutopfhvcZLmcfl_, etc.
// Delete any property whose name starts with 'cdc_' or '_cdc_'.
try {
Object.getOwnPropertyNames(window).forEach(function(prop) {
if (prop.startsWith('cdc_') || prop.startsWith('_cdc_')) {
try { delete window[prop]; } catch(_) {}
}
});
} catch(_) {}
// Chromium headless-mode automation controller bindings.
try { delete window.domAutomation; } catch(_) {}
try { delete window.domAutomationController; } catch(_) {}
// Document-level $cdc_ artifact (ChromeDriver adds this on the document).
try {
if (typeof document !== 'undefined') {
Object.getOwnPropertyNames(document).forEach(function(prop) {
if (prop.startsWith('$cdc_') || prop.startsWith('cdc_')) {
try { delete document[prop]; } catch(_) {}
}
});
}
} catch(_) {}
})();
";
const ADD_BINDING_FIX: &str = r"
// addBinding anti-detection: override CDP binding channels
(function() {
// Remove chrome.loadTimes and chrome.csi (automation markers)
if (window.chrome) {
try {
delete window.chrome.loadTimes;
delete window.chrome.csi;
} catch(_) {}
}
// Ensure chrome runtime looks authentic
if (!window.chrome) {
Object.defineProperty(window, 'chrome', {
value: { runtime: {} },
configurable: true,
});
}
// Override Notification.permission to avoid prompts exposing automation
if (typeof Notification !== 'undefined') {
Object.defineProperty(Notification, 'permission', {
get: () => 'default',
configurable: true,
});
}
})();
";
const ISOLATED_WORLD_NOTE: &str = r"
// Isolated-world mode: minimal injection — scripts run in isolated CDP context
(function() { /* isolated world active */ })();
";
const ENABLE_DISABLE_NOTE: &str = r"
// Enable/disable mode: Runtime toggled per-evaluation (best effort)
(function() { /* enable-disable guard active */ })();
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_mode_is_add_binding() {
let mode = CdpFixMode::AddBinding;
assert_ne!(mode, CdpFixMode::None);
}
#[test]
fn none_mode_produces_empty_script() {
let p = CdpProtection::new(CdpFixMode::None, None);
assert!(p.build_injection_script().is_empty());
assert!(!p.is_active());
}
#[test]
fn add_binding_script_removes_webdriver() {
let p = CdpProtection::new(CdpFixMode::AddBinding, None);
let script = p.build_injection_script();
assert!(script.contains("navigator"));
assert!(script.contains("webdriver"));
assert!(!script.is_empty());
}
#[test]
fn source_url_patch_included_by_default() {
let p = CdpProtection::new(CdpFixMode::AddBinding, None);
let script = p.build_injection_script();
assert!(script.contains("app.js"));
assert!(script.contains("sourceURL") || script.contains("pptr"));
}
#[test]
fn custom_source_url_in_script() {
let p = CdpProtection::new(CdpFixMode::AddBinding, Some("bundle.js".to_string()));
let script = p.build_injection_script();
assert!(script.contains("bundle.js"));
}
#[test]
fn source_url_patch_disabled_when_zero() {
let p = CdpProtection::new(CdpFixMode::AddBinding, Some("0".to_string()));
let script = p.build_injection_script();
assert!(!script.contains("Function.prototype.toString"));
}
#[test]
fn isolated_world_mode_not_none() {
let p = CdpProtection::new(CdpFixMode::IsolatedWorld, None);
assert!(p.is_active());
assert!(!p.build_injection_script().is_empty());
}
#[test]
fn cdp_fix_mode_from_env_parses_none() {
assert_eq!(CdpFixMode::None, CdpFixMode::None);
assert_ne!(CdpFixMode::None, CdpFixMode::AddBinding);
}
#[test]
fn automation_artifact_cleanup_included_in_all_active_modes() {
for mode in [
CdpFixMode::AddBinding,
CdpFixMode::IsolatedWorld,
CdpFixMode::EnableDisable,
] {
let p = CdpProtection::new(mode, None);
let script = p.build_injection_script();
assert!(
script.contains("cdc_"),
"mode {mode:?} missing cdc_ cleanup"
);
assert!(
script.contains("domAutomation"),
"mode {mode:?} missing domAutomation cleanup"
);
}
}
#[test]
fn automation_artifact_cleanup_absent_in_none_mode() {
let p = CdpProtection::new(CdpFixMode::None, None);
let script = p.build_injection_script();
assert!(script.is_empty());
}
}