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
//! PerimeterX / HUMAN Security dedicated solver.
//!
//! PerimeterX (rebranded as HUMAN Bot Defender in 2022, but still
//! commonly referred to by the original name) protects ~5% of top-1M
//! sites. Its detection lifecycle:
//!
//! 1. **Sensor JS** loads from `client.px-cdn.net` or
//!    `human-security.com/captcha`, gathers fingerprint + behavior
//!    signals, POSTs to a sensor endpoint.
//! 2. On accept, server sets the **`_px3`** cookie (the v3 protocol
//!    session token; older deployments used `_pxhd` / `_pxvid`).
//! 3. On reject or low-confidence, page surfaces the **`#px-captcha`**
//!    Press-and-Hold widget (a horizontal slider variant that
//!    requires a sustained press, unlike GeeTest's drag-and-release).
//!    The generic [`super::super::SliderCaptchaSolver`] doesn't
//!    handle press-and-hold geometry; we yield to a screenshot+VLM
//!    fallback in that case.
//! 4. On hard block, page renders an error banner with a reference
//!    code (surfaced as failure so the chain falls through).
//!
//! This solver covers cases 1, 2, and the yield-to-VLM signaling for
//! case 3. It is structurally identical to the DataDome and Akamai
//! interstitial solvers; the cookie shape and selectors differ.

use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};

/// Default total wait budget. PerimeterX sensor POST typically
/// completes within 3–6s; 20s upper bound matches sibling
/// interstitial solvers for symmetry.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// PerimeterX / HUMAN Security cookie-pass + sensor-wait solver.
pub struct PerimeterXSolver {
    max_wait_ms: u64,
}

impl Default for PerimeterXSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl PerimeterXSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

/// Pure classifier for the `_px3` (or `_pxhd` legacy) cookie.
///
/// PerimeterX session cookies are short-form base64url segments
/// joined by colons: typically `<8 alphanumerics>:<base64>:<numeric>:<base64>`.
/// A real `_px3` value is consistently 30+ chars; placeholder cookies
/// or empty values are shorter. The classifier is intentionally
/// permissive on the inner-segment shape. PerimeterX has rotated the
/// payload format twice in three years, so byte-exact validation
/// would break on the next refresh, but firm on the length floor
/// because that has held across all observed revisions.
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::perimeterx::{classify_px_cookie, PxCookie};
///
/// assert_eq!(classify_px_cookie(""), PxCookie::Missing);
/// assert_eq!(classify_px_cookie("short"), PxCookie::Pending);
/// // Real-shape session cookie: 30+ chars, colon-separated segments
/// let real = "AbCdEfGh:0:abcdefghijklmnopqrstuvwxyz0123456789ABCDEF:";
/// assert_eq!(classify_px_cookie(real), PxCookie::Valid);
/// ```
pub fn classify_px_cookie(value: &str) -> PxCookie {
    if value.is_empty() {
        return PxCookie::Missing;
    }
    let trimmed = value.trim_matches('"');
    if trimmed.is_empty() {
        return PxCookie::Missing;
    }
    if trimmed.len() < 30 {
        return PxCookie::Pending;
    }
    // Must contain at least one colon, single-token cookies of any
    // length aren't the v3 shape. (The legacy `_pxhd` device-history
    // cookie is two segments joined by `_`; both have a separator.)
    if !trimmed.contains(':') && !trimmed.contains('_') {
        return PxCookie::Pending;
    }
    PxCookie::Valid
}

/// Validity classification of a PerimeterX session cookie.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PxCookie {
    /// No cookie at all.
    Missing,
    /// Cookie present but in pre-sensor / placeholder shape.
    Pending,
    /// Cookie carries a real-shape v3 (or legacy device-history) token.
    Valid,
}

/// JS that probes the page for PerimeterX state. Returns:
///   - `{ phase: "blocked", ref_code: "..." }`: explicit block
///     page with reference code (PX/HUMAN's standard error UX)
///   - `{ phase: "press_and_hold" }`: the `#px-captcha` widget is
///     present; chain should yield to VLM (we don't ship a dedicated
///     press-and-hold solver yet)
///   - `{ phase: "interstitial" }`: sensor script loaded but no
///     valid cookie yet
///   - `{ phase: "passed", cookie: "..." }`: `_px3` (or `_pxhd`)
///     cookie set, classifier decides validity
const PHASE_PROBE_JS: &str = r#"
(() => {
    const title = (document.title || '').toLowerCase();
    const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
    /* Block page detection: PX uses a specific banner with a
       reference code formatted "Reference ID:". */
    if (
        title.includes('access denied') ||
        body.includes('blocked') && (body.includes('reference id') || body.includes('px'))
    ) {
        const m = body.match(/reference id[:\s]+([a-z0-9-]+)/i);
        return { phase: 'blocked', ref_code: m ? m[1] : '' };
    }
    /* Press-and-hold widget. PX's distinctive UX. */
    if (document.querySelector('#px-captcha, .px-captcha-error-container, [data-px-captcha]')) {
        return { phase: 'press_and_hold' };
    }
    /* Cookie pass, check both v3 and legacy device-history cookies. */
    let px3 = '', pxhd = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        if (t.startsWith('_px3=')) { px3 = t.slice('_px3='.length); }
        else if (t.startsWith('_pxhd=')) { pxhd = t.slice('_pxhd='.length); }
    }
    /* Prefer _px3 (v3 protocol); fall back to _pxhd (device-history,
       still issued on legacy tenants). */
    const cookie = px3 || pxhd;
    if (cookie) return { phase: 'passed', cookie };
    /* Sensor script presence: PX has used several CDN domains. */
    const sensorPresent =
        !!document.querySelector('script[src*="client.px-cdn.net"], script[src*="captcha.px-cdn.net"], script[src*="human-security.com"]') ||
        typeof window._pxAppId !== 'undefined' ||
        typeof window.PX !== 'undefined' ||
        typeof window._humanSecurity !== 'undefined';
    if (sensorPresent) return { phase: 'interstitial' };
    return { phase: 'unknown' };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    cookie: String,
    #[serde(default)]
    #[serde(rename = "ref_code")]
    _ref_code: String,
}

#[async_trait]
impl CaptchaSolver for PerimeterXSolver {
    fn name(&self) -> &'static str {
        "PerimeterXSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::AutoPass
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // PerimeterX is detected as Custom("perimeterx_human") by
        // the bundled community rule. SliderCaptcha is also accepted
        // because PX's press-and-hold sometimes round-trips through
        // the slider detector.
        matches!(
            kind,
            DetectedCaptcha::Custom(_) | DetectedCaptcha::SliderCaptcha
        )
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let deadline = t0 + Duration::from_millis(self.max_wait_ms);

        loop {
            let raw = page
                .evaluate(PHASE_PROBE_JS)
                .await
                .map_err(|e| anyhow!("PerimeterX probe failed: {e}"))?;
            let probe: PhaseProbe = match raw.into_value() {
                Ok(p) => p,
                Err(_) => PhaseProbe {
                    phase: "interstitial".into(),
                    cookie: String::new(),
                    _ref_code: String::new(),
                },
            };

            match probe.phase.as_str() {
                "passed" if classify_px_cookie(&probe.cookie) == PxCookie::Valid => {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: "perimeterx:_px3".into(),
                        confidence: 1.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                        verified_outcome: None,
                    });
                }
                "passed" => {
                    // Cookie set but pending (keep waiting).
                }
                "press_and_hold" => {
                    // We don't ship a dedicated press-and-hold solver
                    // yet; surface a screenshot for the VLM/human
                    // fallback and report failure so the chain
                    // advances to the next solver.
                    let shot = super::super::screenshot_b64(page).await.ok();
                    return Ok(CaptchaSolveResult {
                        solution: String::new(),
                        confidence: 0.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: false,
                        screenshot: shot,
                        cookies: Vec::new(),
                        verified_outcome: None,
                    });
                }
                "blocked" => {
                    return Ok(CaptchaSolveResult::failure(
                        SolveMethod::AutoPass,
                        t0.elapsed().as_millis() as u64,
                    ));
                }
                _ => {} // still on interstitial, keep waiting
            }

            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }
}

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

    #[test]
    fn defaults_are_sane() {
        assert_eq!(PerimeterXSolver::new().max_wait_ms, 20_000);
    }

    #[test]
    fn builders_override() {
        assert_eq!(
            PerimeterXSolver::new().with_max_wait_ms(8_000).max_wait_ms,
            8_000
        );
    }

    #[test]
    fn name_method_stable() {
        let s = PerimeterXSolver::new();
        assert_eq!(s.name(), "PerimeterXSolver");
        assert_eq!(s.method(), SolveMethod::AutoPass);
    }

    #[test]
    fn supports_custom_and_slider() {
        let s = PerimeterXSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
        assert!(s.supports(&DetectedCaptcha::SliderCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::HCaptcha));
        assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
    }

    #[test]
    fn classify_px_cookie_empty_is_missing() {
        assert_eq!(classify_px_cookie(""), PxCookie::Missing);
        assert_eq!(classify_px_cookie("\"\""), PxCookie::Missing);
    }

    #[test]
    fn classify_px_cookie_short_is_pending() {
        assert_eq!(classify_px_cookie("short"), PxCookie::Pending);
        assert_eq!(
            classify_px_cookie("aaaaaaaaaaaaaaaaaaaaaaaaaaa"),
            PxCookie::Pending
        ); // 27 chars
    }

    #[test]
    fn classify_px_cookie_long_no_separator_is_pending() {
        // Length passes but no colon/underscore (not real shape).
        let s = "a".repeat(40);
        assert_eq!(classify_px_cookie(&s), PxCookie::Pending);
    }

    #[test]
    fn classify_px_cookie_v3_shape_is_valid() {
        // Real-shape v3 cookies use colon-separated segments.
        let real = "AbCdEfGh:0:abcdefghijklmnopqrstuvwxyz0123456789ABCDEF:";
        assert_eq!(classify_px_cookie(real), PxCookie::Valid);
    }

    #[test]
    fn classify_px_cookie_legacy_device_history_is_valid() {
        // Legacy _pxhd uses `_` as separator; still a valid shape.
        let legacy = "abcdefghijklmnopqrstuvwxyz0123456789_abcdefghijklmn";
        assert_eq!(classify_px_cookie(legacy), PxCookie::Valid);
    }

    #[test]
    fn classify_px_cookie_quoted_value() {
        let real = "\"AbCdEfGh:0:abcdefghijklmnopqrstuvwxyz0123456789:\"";
        assert_eq!(classify_px_cookie(real), PxCookie::Valid);
    }

    #[test]
    fn phase_probe_js_covers_documented_signals() {
        for needle in [
            "_px3=",
            "_pxhd=",
            "client.px-cdn.net",
            "captcha.px-cdn.net",
            "human-security.com",
            "_pxappid",
            "_humansecurity",
            "px-captcha",
            "press_and_hold",
        ] {
            assert!(
                PHASE_PROBE_JS.to_lowercase().contains(needle),
                "PHASE_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn phase_probe_js_emits_canonical_phases() {
        for phase in [
            "blocked",
            "press_and_hold",
            "interstitial",
            "passed",
            "unknown",
        ] {
            assert!(
                PHASE_PROBE_JS.contains(&format!("'{phase}'")),
                "PHASE_PROBE_JS must emit phase: {phase}"
            );
        }
    }
}