captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! TOML-driven captcha detector rules, community-extensible
//! detection without writing Rust.
//!
//! Most new captcha vendors are detectable from one or more of:
//!   * a CSS selector (`.aws-waf-token`, `[data-datadome]`)
//!   * a `<script src=...>` substring (`awswafcaptcha.com`)
//!   * a `window.<global>` value being defined
//!
//! [`RuleSet`] loads a TOML file describing one provider per
//! `[[provider]]` table. Each rule is converted into a synthetic
//! [`crate::captcha_detect::Detector`] that emits a small,
//! deterministic JS probe, never executes user-supplied JS, and
//! returns a [`DetectedCaptcha::Custom`] match on hit.
//!
//! ## Schema
//!
//! ```toml
//! [[provider]]
//! name = "aws_waf"
//! priority = 12       # ordered with built-in detectors; lower = earlier
//!
//! [provider.triggers]
//! selectors            = [".aws-waf-token", "[data-aws-waf]"]
//! window_globals       = ["awsWafCaptcha"]
//! script_src_contains  = ["awswafcaptcha.com"]
//! ```
//!
//! Any single trigger matching means a positive detection, the rule
//! semantics are OR across categories, OR within each category. To
//! keep generated JS small + safe, EVERY trigger value is escaped
//! before substitution; raw JS payloads are NOT supported on purpose.
//!
//! Operators that need imperative detection logic (BiDi probes, async
//! waits, behavioural fingerprints) implement [`Detector`] in Rust
//! the same way the built-in detectors do.
use crate::Page;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;

use super::{parse_detection_result, CaptchaInfo, DetectedCaptcha, Detector};
use crate::provider::CaptchaProvider;
use crate::solver::{CaptchaType, SolveMethod};

/// One row from a rules TOML file.
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderRule {
    /// Stable identifier; surfaces in logs and as
    /// [`DetectedCaptcha::Custom`] on hit.
    pub name: String,
    /// Priority for the detector registry (lower runs earlier).
    /// Choose a value that doesn't collide with built-in detectors
    /// (5, 10, 20, 30, 40, 50, 60, 65, 75, 85, 90 are taken).
    pub priority: i32,
    /// Solver methods, ordered by recommended preference. Surfaced
    /// to the chain via [`CaptchaProvider::recommended_solver_methods`]
    /// when the rule is registered through [`super::super::provider::
    /// ProviderRegistry::with_built_in_rules`]. Defaults to a
    /// behavioral-first chain when omitted.
    #[serde(default = "default_solver_methods")]
    pub solver_methods: Vec<SolveMethod>,
    /// Solver names (matched against `solver.name()`), ordered by
    /// recommended preference. When non-empty, the chain prefers
    /// this list over `solver_methods`: name-based routing is
    /// precise and avoids collisions when multiple solvers share a
    /// `SolveMethod`. Vendors with dedicated solvers
    /// (`SliderCaptchaSolver`, `CloudflareInterstitialSolver`, etc.)
    /// should use this field. Empty default keeps the legacy
    /// method-based path for back-compat.
    #[serde(default)]
    pub solver_names: Vec<String>,
    /// Triggers for the rule. ANY single trigger matching = detection.
    pub triggers: Triggers,
}

fn default_solver_methods() -> Vec<SolveMethod> {
    vec![
        SolveMethod::BehavioralBypass,
        SolveMethod::ThirdPartyService,
    ]
}

/// One rule's trigger predicates. All fields are optional; an empty
/// triggers block makes the rule a permanent no-match.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Triggers {
    /// CSS selectors to query against the live document. Any selector
    /// that finds at least one element fires the rule.
    #[serde(default)]
    pub selectors: Vec<String>,
    /// `window.<name>` values that must be defined (typeof != undefined).
    #[serde(default)]
    pub window_globals: Vec<String>,
    /// Substrings to look for in `<script src>` attributes.
    #[serde(default)]
    pub script_src_contains: Vec<String>,
    /// Case-insensitive substrings to match against `document.title`.
    /// Useful for "Checking your browser…" / "DDoS protection by ___"
    /// interstitials that don't ship a stable selector.
    #[serde(default)]
    pub title_contains: Vec<String>,
    /// Cookie names that, when present (regardless of value), indicate
    /// the vendor's challenge cycle is in progress. Lets us recognise
    /// in-flight challenges even when the visible DOM has settled
    /// (e.g. a passive cookie issued by Imperva, Reblaze, Kasada).
    #[serde(default)]
    pub cookie_names: Vec<String>,
}

/// Parsed rules file (a flat collection of [`ProviderRule`]s).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct RuleSet {
    #[serde(default, rename = "provider")]
    pub providers: Vec<ProviderRule>,
}

/// The community-contributed rule pack bundled with the crate. Covers
/// DataDome, Akamai Bot Manager, PerimeterX/HUMAN, Arkose/FunCaptcha,
/// Geetest v3 + v4, AWS WAF Captcha, Friendly Captcha, MTCaptcha, and
/// WordPress math captchas.
///
/// Use [`built_in_rules`] to materialise the parsed [`RuleSet`] and
/// register its detectors:
///
/// ```rust
/// use captchaforge::detect::rules::built_in_rules;
///
/// let rules = built_in_rules().expect("bundled rules parse");
/// assert!(rules.providers.iter().any(|p| p.name == "datadome"));
/// ```
pub const BUILT_IN_RULES_TOML: &str = include_str!("../../rules/community.toml");

/// Parse the bundled community rule pack (see [`BUILT_IN_RULES_TOML`]).
///
/// Always succeeds for a clean checkout; returns `Err` only if the
/// bundled TOML has been corrupted, which would also fail the
/// `rules::tests::built_in_rules_parse` regression.
pub fn built_in_rules() -> Result<RuleSet> {
    RuleSet::parse(BUILT_IN_RULES_TOML)
}

impl RuleSet {
    /// Parse a rules TOML string. Use [`Self::load_from_path`] when
    /// reading from disk.
    pub fn parse(input: &str) -> Result<Self> {
        toml::from_str(input).context("parsing TOML rules")
    }

    /// Load a rules file from disk.
    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)
    }

    /// Build [`Detector`]s for every rule. Returned in insertion order;
    /// the [`crate::captcha_detect::DetectorRegistry`] sorts them by
    /// priority when added.
    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()
    }
}

/// Runtime detector built from a [`ProviderRule`]. Owns the rule + a
/// pre-computed `&'static`-style JS probe so per-detect calls are
/// allocation-free in the hot path.
pub struct RuleDetector {
    rule: ProviderRule,
    js: String,
    // We need a 'static name for the Detector trait, so we leak the
    // rule name on construction. Rules live for the program's
    // lifetime, so this is correct (and matches the source_scan
    // pattern in wptrace).
    leaked_name: &'static str,
    /// `Box::leak`-ed copy of the rule's solver_methods so we can
    /// satisfy [`CaptchaProvider::recommended_solver_methods`]'s
    /// `&'static [SolveMethod]` return type without allocating per
    /// call. Lives for the program's lifetime, same rationale as
    /// `leaked_name`.
    leaked_methods: &'static [SolveMethod],
    /// `Box::leak`-ed copy of the rule's solver_names so we can
    /// satisfy [`CaptchaProvider::recommended_solver_names`]'s
    /// `&'static [&'static str]` return type. Each name string is
    /// also leaked individually.
    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());
        // Two-stage leak for &'static [&'static str]: leak each
        // string, collect the &'static refs, then leak the 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 {
    /// Borrow the underlying rule (for diagnostics / tests).
    pub fn rule(&self) -> &ProviderRule {
        &self.rule
    }
    /// Borrow the generated JS probe.
    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>()?;
        // parse_detection_result lands the JS-side `kind: "custom:<name>"`
        // string into DetectedCaptcha::Custom, so the returned info.kind
        // is already correct (pass through).
        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
    }
}

/// Generate a small, escaped JS IIFE that probes every trigger of the
/// rule. The payload is deterministic: same rule → same string.
///
/// Every interpolated value is JSON-escaped (`json_str`) so a hostile
/// rule can't break out of the string context, the rules layer is a
/// data layer, never a code-execution surface.
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(){");

    // Selectors: one querySelector per entry.
    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}}};}}",
        ));
    }

    // Window globals: typeof check so we don't trigger ReferenceError.
    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}}};}}",
        ));
    }

    // Script src substrings: scan all script[src] for substring match.
    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('}');
    }

    // document.title substring matches, case-insensitive. Useful for
    // interstitial pages whose only stable signal is the title bar.
    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}}};}}",
            ));
        }
    }

    // Cookie-name presence: split document.cookie on `;`, trim, match
    // against each named cookie. Doesn't read the value, presence is
    // the signal.
    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
}

/// JSON-quote an arbitrary string so it's safe to embed in a JS
/// expression. Uses `serde_json::to_string` rather than hand-rolling
/// escapes (there is no scenario where a serialised string fails).
fn json_str(s: &str) -> String {
    serde_json::to_string(s).expect("serde_json never fails on &str")
}

#[cfg(test)]
#[path = "rules/tests.rs"]
mod tests;