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
//! Kasada bot-defense dedicated solver.
//!
//! Kasada protects high-value targets (X/Twitter, Canada Goose, Hotels.com,
//! ticketing). First contact serves a minimal interruption page that loads
//! `ips.js`, which runs a WASM/JS proof-of-work, POSTs the result, and on
//! success sets the `KP_UIDz` / `KP_UIDz-ssn` cookies and the `x-kpsdk-ct`
//! (challenge) + `x-kpsdk-cd` (compute-done) request tokens, then the real
//! content loads with `window.KPSDK` initialised.
//!
//! **Scope (honest):** this solver does NOT reverse Kasada's POW, that is a
//! WASM target Kasada rotates aggressively, and any byte emulator breaks on the
//! next refresh. It relies on the real Firefox executing `ips.js` under a
//! coherent stealth fingerprint, then *confirms* the resulting clearance shape
//! (`KP_UIDz` set + `KPSDK` initialised + the interruption gone) and harvests
//! the cookies. It reports `Valid` only when it can actually see that clearance;
//! otherwise it yields within its budget and the chain falls through to the
//! generic wait/third-party path, so it can only ever *add* a confident pass,
//! never mask a failure as success.
//!
//! # Pass criteria
//!
//! `KP_UIDz` cookie present and long-form, AND `window.KPSDK` initialised, AND
//! the `ips.js` interruption is no longer the active document. A bare `KP_UIDz`
//! with the challenge still present is treated as pending (the POW is mid-flight
//! or failing).

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

/// Default wait budget. Kasada's POW + reload typically completes within 4–9s
/// on a passing fingerprint; 20s matches the other vendor interstitial solvers.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// Minimum length for a `KP_UIDz` value to count as a real post-POW token
/// (genuine values are long opaque strings; a short value is a placeholder).
const KP_UIDZ_MIN_LEN: usize = 24;

/// Kasada interruption / cookie-pass solver.
pub struct KasadaSolver {
    max_wait_ms: u64,
}

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

impl KasadaSolver {
    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
    }
}

/// Clearance classification for a Kasada session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KasadaClearance {
    /// No `KP_UIDz` token present.
    Missing,
    /// Token present but the interruption is still active / token is a
    /// placeholder (the POW is mid-flight or failing).
    Pending,
    /// Long-form `KP_UIDz` present with the interruption gone. Kasada accepted
    /// the session.
    Valid,
}

/// Pure classifier for Kasada clearance.
///
/// `Valid` requires a long-form `KP_UIDz` AND that the `ips.js` interruption is
/// no longer present (`challenge_present == false`), the conservative
/// definition that avoids reporting a pass while the POW is still running.
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::kasada::{classify_kasada, KasadaClearance};
///
/// let tok = "kpuidz".repeat(6); // 36 chars
/// assert_eq!(classify_kasada(&tok, false), KasadaClearance::Valid);
/// assert_eq!(classify_kasada(&tok, true), KasadaClearance::Pending); // POW still up
/// assert_eq!(classify_kasada("short", false), KasadaClearance::Pending);
/// assert_eq!(classify_kasada("", false), KasadaClearance::Missing);
/// ```
pub fn classify_kasada(kp_uidz: &str, challenge_present: bool) -> KasadaClearance {
    let kp = kp_uidz.trim().trim_matches('"');
    if kp.is_empty() {
        return KasadaClearance::Missing;
    }
    if kp.len() >= KP_UIDZ_MIN_LEN && !challenge_present {
        return KasadaClearance::Valid;
    }
    KasadaClearance::Pending
}

/// JS that probes the page for Kasada signals. Returns raw signals; Rust
/// decides the phase via [`classify_kasada`].
///
/// Fields: `challenge` (`ips.js` interruption still active), `kpUidz` (cookie),
/// `kpsdk` (`window.KPSDK` initialised).
const SIGNAL_PROBE_JS: &str = r#"
(() => {
    let kpsdk = false;
    try { kpsdk = typeof window.KPSDK !== 'undefined'; } catch (e) { kpsdk = false; }
    // The interruption is "active" while ips.js is referenced AND KPSDK has not
    // yet signalled ready. Once the real content loads, ips.js is no longer the
    // document's controlling script.
    let ipsPresent = false;
    try {
        ipsPresent = !!document.querySelector('script[src*="/ips.js"], script[src*="/149e"], [id*="kpsdk"]');
    } catch (e) { ipsPresent = false; }
    let kready = false;
    try { kready = !!(window.KPSDK && (window.KPSDK.configured || window.KPSDK.message)); } catch (e) { kready = false; }
    const challenge = ipsPresent && !kready;
    let kp = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        if (t.indexOf('KP_UIDz=') === 0) { kp = t.slice('KP_UIDz='.length); break; }
    }
    return { challenge: challenge, kpUidz: kp, kpsdk: kpsdk };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct KasadaSignals {
    #[serde(default)]
    challenge: bool,
    #[serde(default, rename = "kpUidz")]
    kp_uidz: String,
    #[serde(default)]
    kpsdk: bool,
}

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        matches!(kind, DetectedCaptcha::Custom(s) if s == "kasada")
    }

    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(SIGNAL_PROBE_JS)
                .await
                .map_err(|e| anyhow!("Kasada probe failed: {e}"))?;
            let sig: KasadaSignals = raw.into_value().unwrap_or(KasadaSignals {
                challenge: true,
                kp_uidz: String::new(),
                kpsdk: false,
            });

            // Require KPSDK to be initialised in addition to a long-form token
            // with the interruption gone (the conservative pass definition).
            if sig.kpsdk && classify_kasada(&sig.kp_uidz, sig.challenge) == KasadaClearance::Valid {
                let cookies = crate::cookies::capture_from_page(page)
                    .await
                    .unwrap_or_default();
                return Ok(CaptchaSolveResult {
                    solution: "kasada:cookie".into(),
                    confidence: 1.0,
                    method: SolveMethod::AutoPass,
                    time_ms: t0.elapsed().as_millis() as u64,
                    success: true,
                    screenshot: None,
                    cookies,
                    verified_outcome: None,
                });
            }

            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_and_builder() {
        assert_eq!(KasadaSolver::new().max_wait_ms, 20_000);
        assert_eq!(
            KasadaSolver::new().with_max_wait_ms(9_000).max_wait_ms,
            9_000
        );
    }

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

    #[test]
    fn supports_only_kasada_custom() {
        let s = KasadaSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("kasada".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("datadome".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("imperva_incapsula".into())));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
    }

    #[test]
    fn long_token_no_challenge_is_valid() {
        let tok = "kpuidz".repeat(6); // 36 chars
        assert_eq!(classify_kasada(&tok, false), KasadaClearance::Valid);
    }

    #[test]
    fn long_token_with_challenge_is_pending() {
        let tok = "kpuidz".repeat(6);
        assert_eq!(classify_kasada(&tok, true), KasadaClearance::Pending);
    }

    #[test]
    fn short_token_is_pending() {
        assert_eq!(classify_kasada("short", false), KasadaClearance::Pending);
    }

    #[test]
    fn empty_token_is_missing() {
        assert_eq!(classify_kasada("", false), KasadaClearance::Missing);
        assert_eq!(classify_kasada("\"\"", false), KasadaClearance::Missing);
    }

    #[test]
    fn quoted_token_trimmed() {
        let tok = format!("\"{}\"", "kpuidz".repeat(6));
        assert_eq!(classify_kasada(&tok, false), KasadaClearance::Valid);
    }

    #[test]
    fn signal_probe_js_covers_documented_signals() {
        for needle in ["kpsdk", "ips.js", "kp_uidz="] {
            assert!(
                SIGNAL_PROBE_JS.to_lowercase().contains(needle),
                "SIGNAL_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn signal_probe_js_is_bracket_balanced() {
        let mut depth = 0i32;
        for c in SIGNAL_PROBE_JS.chars() {
            match c {
                '(' | '{' | '[' => depth += 1,
                ')' | '}' | ']' => depth -= 1,
                _ => {}
            }
            assert!(depth >= 0);
        }
        assert_eq!(depth, 0);
    }
}