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
//! HTTP block-page markers shared with erroracle.
//!
//! Captcha **type** detectors (`turnstile`, `recaptcha`, `hcaptcha`, …) stay in
//! this crate. Generic block disposition and vendor **tokens** in response text
//! delegate to [`erroracle`](../../../../libs/scanner/erroracle). **Named WAF
//! product** identification is owned by [`wafrift_detect`](../../wafrift/crates/detect).

use erroracle::{
    body_has_waf_block_phrases, body_has_waf_markers, classify_response, HttpResponse,
    ResponseDisposition,
};

/// Interstitial phrases on captcha verify paths not covered by erroracle alone.
pub const CAPTCHAFORGE_BLOCK_EXTRAS: &[&str] = &[
    "sorry, you have been blocked",
    "pardon our interruption",
    "your request has been blocked",
    "this request has been blocked",
    "the owner of this website",
    "request unsuccessful",
    "you don't have permission",
    "automated access",
    "bot detected",
    "suspicious activity",
    "you have been rate limited",
    "rate limit exceeded",
    "permission denied",
    "security policy",
    "forbidden",
];

/// Whether an HTTP response looks blocked (token-replay / verify endpoints).
#[must_use]
pub fn http_response_indicates_block(status: u16, body: Option<&str>) -> bool {
    if !(200..300).contains(&status) {
        return true;
    }
    let Some(body) = body else {
        return false;
    };
    let lower = body.to_lowercase();
    if extras_indicate_block(&lower) {
        return true;
    }
    if body_has_waf_block_phrases(&lower) {
        return true;
    }
    // Vendor tokens need a blocking status in erroracle; use 403 surrogate for body-only scan.
    if body_has_waf_markers(&lower) {
        return matches!(
            classify_response(&HttpResponse::new(403, body)).disposition,
            ResponseDisposition::WafBlock | ResponseDisposition::RateLimited
        );
    }
    false
}

/// Whether live page title/body text indicates a hard block after a solve attempt.
#[must_use]
pub fn page_text_indicates_hard_block(title: &str, body_excerpt: &str) -> bool {
    let lower = format!("{}\n{}", title.to_lowercase(), body_excerpt.to_lowercase());
    extras_indicate_block(&lower) || body_has_waf_block_phrases(&lower)
}

fn extras_indicate_block(lower: &str) -> bool {
    CAPTCHAFORGE_BLOCK_EXTRAS.iter().any(|p| lower.contains(p))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn http_block_delegates_to_erroracle_phrases() {
        assert!(http_response_indicates_block(
            200,
            Some("Sorry, REQUEST BLOCKED by policy")
        ));
    }

    #[test]
    fn http_block_uses_extras() {
        assert!(http_response_indicates_block(
            200,
            Some("Pardon Our Interruption while we verify")
        ));
    }

    #[test]
    fn http_block_clean_200_accepted() {
        assert!(!http_response_indicates_block(200, Some("{\"ok\": true}")));
    }

    #[test]
    fn page_text_uses_shared_phrases() {
        assert!(page_text_indicates_hard_block(
            "Access Denied",
            "your request was rejected"
        ));
    }

    #[test]
    fn benign_cloudflare_mention_not_hard_block_without_phrase() {
        assert!(!page_text_indicates_hard_block(
            "How Cloudflare works",
            "Cloudflare is a CDN"
        ));
    }
}