use anyhow::{Context, Result};
use async_trait::async_trait;
use chromiumoxide::Page;
use serde::Deserialize;
use super::{parse_detection_result, CaptchaInfo, DetectedCaptcha, Detector};
use crate::provider::CaptchaProvider;
use crate::solver::{CaptchaType, SolveMethod};
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderRule {
pub name: String,
pub priority: i32,
#[serde(default = "default_solver_methods")]
pub solver_methods: Vec<SolveMethod>,
#[serde(default)]
pub solver_names: Vec<String>,
pub triggers: Triggers,
}
fn default_solver_methods() -> Vec<SolveMethod> {
vec![
SolveMethod::BehavioralBypass,
SolveMethod::ThirdPartyService,
]
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Triggers {
#[serde(default)]
pub selectors: Vec<String>,
#[serde(default)]
pub window_globals: Vec<String>,
#[serde(default)]
pub script_src_contains: Vec<String>,
#[serde(default)]
pub title_contains: Vec<String>,
#[serde(default)]
pub cookie_names: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuleSet {
#[serde(default, rename = "provider")]
pub providers: Vec<ProviderRule>,
}
pub const BUILT_IN_RULES_TOML: &str = include_str!("../../rules/community.toml");
pub fn built_in_rules() -> Result<RuleSet> {
RuleSet::parse(BUILT_IN_RULES_TOML)
}
impl RuleSet {
pub fn parse(input: &str) -> Result<Self> {
toml::from_str(input).context("parsing TOML rules")
}
pub fn load_from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
let text = std::fs::read_to_string(path.as_ref())
.with_context(|| format!("reading {}", path.as_ref().display()))?;
Self::parse(&text)
}
pub fn into_detectors(self) -> Vec<Box<dyn Detector>> {
self.providers
.into_iter()
.map(|r| Box::new(RuleDetector::from(r)) as Box<dyn Detector>)
.collect()
}
}
pub struct RuleDetector {
rule: ProviderRule,
js: String,
leaked_name: &'static str,
leaked_methods: &'static [SolveMethod],
leaked_names: &'static [&'static str],
}
impl From<ProviderRule> for RuleDetector {
fn from(rule: ProviderRule) -> Self {
let js = build_probe_js(&rule);
let leaked_name: &'static str = Box::leak(rule.name.clone().into_boxed_str());
let leaked_methods: &'static [SolveMethod] =
Box::leak(rule.solver_methods.clone().into_boxed_slice());
let name_refs: Vec<&'static str> = rule
.solver_names
.iter()
.map(|n| -> &'static str { Box::leak(n.clone().into_boxed_str()) })
.collect();
let leaked_names: &'static [&'static str] = Box::leak(name_refs.into_boxed_slice());
Self {
rule,
js,
leaked_name,
leaked_methods,
leaked_names,
}
}
}
impl RuleDetector {
pub fn rule(&self) -> &ProviderRule {
&self.rule
}
pub fn js(&self) -> &str {
&self.js
}
}
#[async_trait]
impl Detector for RuleDetector {
fn name(&self) -> &'static str {
self.leaked_name
}
fn priority(&self) -> i32 {
self.rule.priority
}
async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>> {
let raw = page.evaluate(self.js.as_str()).await?;
let val = raw.into_value::<serde_json::Value>()?;
Ok(parse_detection_result(val))
}
}
impl CaptchaProvider for RuleDetector {
fn name(&self) -> &'static str {
<Self as Detector>::name(self)
}
fn detected_kind(&self) -> DetectedCaptcha {
DetectedCaptcha::Custom(self.rule.name.clone())
}
fn captcha_type(&self) -> CaptchaType {
CaptchaType::Custom(self.rule.name.clone())
}
fn recommended_solver_methods(&self) -> &'static [SolveMethod] {
self.leaked_methods
}
fn recommended_solver_names(&self) -> &'static [&'static str] {
self.leaked_names
}
fn detector(&self) -> &dyn Detector {
self
}
}
fn build_probe_js(rule: &ProviderRule) -> String {
let kind_str = json_str(&format!("custom:{}", rule.name));
let container_str = json_str("(rule-derived)");
let mut body = String::from("(function(){");
for sel in &rule.triggers.selectors {
let sel_lit = json_str(sel);
body.push_str(&format!(
"if(document.querySelector({sel_lit})){{return {{kind:{kind_str},site_key:null,container:{container_str}}};}}",
));
}
for global in &rule.triggers.window_globals {
let g_lit = json_str(global);
body.push_str(&format!(
"if(typeof window[{g_lit}]!=='undefined'){{return {{kind:{kind_str},site_key:null,container:{container_str}}};}}",
));
}
if !rule.triggers.script_src_contains.is_empty() {
body.push_str("var __cfg_scripts=document.querySelectorAll('script[src]');");
body.push_str("for(var __i=0;__i<__cfg_scripts.length;__i++){var __src=__cfg_scripts[__i].getAttribute('src')||'';");
for sub in &rule.triggers.script_src_contains {
let s_lit = json_str(sub);
body.push_str(&format!(
"if(__src.indexOf({s_lit})>=0){{return {{kind:{kind_str},site_key:null,container:{container_str}}};}}",
));
}
body.push('}');
}
if !rule.triggers.title_contains.is_empty() {
body.push_str("var __cfg_title=(document.title||'').toLowerCase();");
for sub in &rule.triggers.title_contains {
let s_lit = json_str(&sub.to_lowercase());
body.push_str(&format!(
"if(__cfg_title.indexOf({s_lit})>=0){{return {{kind:{kind_str},site_key:null,container:{container_str}}};}}",
));
}
}
if !rule.triggers.cookie_names.is_empty() {
body.push_str("var __cfg_cookies=(document.cookie||'').split(';').map(function(c){return c.trim().split('=')[0]||'';});");
for cookie in &rule.triggers.cookie_names {
let c_lit = json_str(cookie);
body.push_str(&format!(
"if(__cfg_cookies.indexOf({c_lit})>=0){{return {{kind:{kind_str},site_key:null,container:{container_str}}};}}",
));
}
}
body.push_str("return {kind:'none',site_key:null,container:null};})()");
body
}
fn json_str(s: &str) -> String {
serde_json::to_string(s).expect("serde_json never fails on &str")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_rule() {
let toml = r#"
[[provider]]
name = "aws_waf"
priority = 12
[provider.triggers]
selectors = [".aws-waf-token"]
"#;
let set = RuleSet::parse(toml).unwrap();
assert_eq!(set.providers.len(), 1);
assert_eq!(set.providers[0].name, "aws_waf");
assert_eq!(set.providers[0].priority, 12);
assert_eq!(set.providers[0].triggers.selectors, vec![".aws-waf-token"]);
}
#[test]
fn parses_title_and_cookie_triggers() {
let toml = r#"
[[provider]]
name = "ddos_screen"
priority = 50
[provider.triggers]
title_contains = ["DDoS protection", "Checking your browser"]
cookie_names = ["__cf_bm", "_dd_jwt"]
"#;
let set = RuleSet::parse(toml).unwrap();
assert_eq!(set.providers[0].triggers.title_contains.len(), 2);
assert_eq!(set.providers[0].triggers.cookie_names.len(), 2);
let det: RuleDetector = set.providers[0].clone().into();
let js = det.js();
assert!(js.contains("document.title"), "title trigger missing from JS: {js}");
assert!(js.contains("document.cookie"), "cookie trigger missing from JS: {js}");
assert!(js.contains("ddos protection"));
}
#[test]
fn parses_multiple_rules() {
let toml = r#"
[[provider]]
name = "aws_waf"
priority = 12
[provider.triggers]
selectors = [".aws-waf-token"]
window_globals = ["awsWafCaptcha"]
[[provider]]
name = "datadome"
priority = 13
[provider.triggers]
selectors = ["[data-datadome]"]
script_src_contains = ["js.datadome.co"]
"#;
let set = RuleSet::parse(toml).unwrap();
assert_eq!(set.providers.len(), 2);
assert_eq!(set.providers[1].name, "datadome");
}
#[test]
fn empty_rules_block_yields_no_providers() {
let set = RuleSet::parse("").unwrap();
assert!(set.providers.is_empty());
}
#[test]
fn missing_required_field_is_an_error() {
let toml = r#"
[[provider]]
priority = 10
[provider.triggers]
"#;
assert!(RuleSet::parse(toml).is_err());
}
#[test]
fn build_probe_js_escapes_selector_with_quotes() {
let rule = ProviderRule {
name: "evil".into(),
priority: 99,
solver_methods: default_solver_methods(),
solver_names: Vec::new(),
triggers: Triggers {
selectors: vec!["[data-x=\"a\"]".into()],
window_globals: vec![],
script_src_contains: vec![],
title_contains: vec![],
cookie_names: vec![],
},
};
let js = build_probe_js(&rule);
assert!(
js.contains("\\\""),
"expected an escaped quote in generated JS; got: {js}"
);
}
#[test]
fn build_probe_js_handles_all_three_trigger_types() {
let rule = ProviderRule {
name: "aws_waf".into(),
priority: 12,
solver_methods: default_solver_methods(),
solver_names: Vec::new(),
triggers: Triggers {
selectors: vec![".aws-waf-token".into()],
window_globals: vec!["awsWafCaptcha".into()],
script_src_contains: vec!["awswafcaptcha.com".into()],
title_contains: vec![],
cookie_names: vec![],
},
};
let js = build_probe_js(&rule);
assert!(js.contains("querySelector(\".aws-waf-token\")"));
assert!(js.contains("typeof window[\"awsWafCaptcha\"]"));
assert!(js.contains("document.querySelectorAll('script[src]')"));
assert!(js.contains("\"awswafcaptcha.com\""));
assert!(js.contains("\"custom:aws_waf\""));
}
#[test]
fn rule_detector_round_trips_name_and_priority() {
let rule = ProviderRule {
name: "datadome".into(),
priority: 13,
solver_methods: default_solver_methods(),
solver_names: Vec::new(),
triggers: Triggers::default(),
};
let det = RuleDetector::from(rule);
assert_eq!(<RuleDetector as Detector>::name(&det), "datadome");
assert_eq!(det.priority(), 13);
}
#[test]
fn built_in_rules_parse_and_cover_expected_vendors() {
let set = built_in_rules().expect("bundled community.toml parses");
let names: Vec<&str> = set.providers.iter().map(|p| p.name.as_str()).collect();
for expected in [
"datadome",
"akamai_bot_manager",
"perimeterx_human",
"arkose_funcaptcha",
"geetest_v3",
"geetest_v4",
"aws_waf_captcha",
"friendly_captcha",
"mtcaptcha",
"wp_math_captcha",
] {
assert!(
names.contains(&expected),
"bundled community.toml is missing rule: {expected}",
);
}
}
#[test]
fn built_in_rules_priorities_dont_collide_with_each_other() {
let set = built_in_rules().unwrap();
let mut prios: Vec<i32> = set.providers.iter().map(|p| p.priority).collect();
prios.sort_unstable();
let mut deduped = prios.clone();
deduped.dedup();
assert_eq!(prios, deduped, "priorities must be unique within the pack");
}
#[test]
fn built_in_rules_become_detectors_with_valid_js_probes() {
let set = built_in_rules().unwrap();
assert!(set.providers.len() >= 10);
for rule in set.providers {
let det = RuleDetector::from(rule);
let js = det.js();
let name = <RuleDetector as Detector>::name(&det);
assert!(js.starts_with("(function()"), "{name}: bad prefix");
assert!(js.ends_with("})()"), "{name}: bad suffix");
assert!(js.contains("custom:"), "{name}: missing custom-kind tag");
}
}
#[test]
fn into_detectors_keeps_insertion_order() {
let toml = r#"
[[provider]]
name = "a"
priority = 100
[provider.triggers]
selectors = [".a"]
[[provider]]
name = "b"
priority = 5
[provider.triggers]
selectors = [".b"]
"#;
let dets = RuleSet::parse(toml).unwrap().into_detectors();
assert_eq!(dets.len(), 2);
assert_eq!(dets[0].name(), "a");
assert_eq!(dets[1].name(), "b");
}
}