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
//! Akamai Bot Manager interstitial solver.
//!
//! Akamai Bot Manager (ABM) protects ~30% of the Fortune-500 web
//! footprint. Its first contact with a fresh session is a sensor-data
//! interstitial: the page loads a heavily-obfuscated `bmak`/`abck`
//! script that gathers browser, mouse, timing, and TLS-handshake
//! signals, POSTs them to a sensor endpoint
//! (`/_Incapsula_Resource`, `/akam/13/...`, `/_sec/v1/...`: varies
//! by tenant), and on success the response sets the `_abck` cookie
//! whose `valid` flag (the third hyphen-separated segment) flips
//! from `0` to `-1` once Akamai accepts the session.
//!
//! Like the Cloudflare interstitial solver, this is NOT an attempt
//! to reverse-engineer the sensor-data format, that's a moving
//! target Akamai actively defends against, and any byte-perfect
//! emulator is one fingerprint refresh away from breaking. Instead,
//! we rely on the fact that with proper stealth + the
//! [`crate::stealth_profiles`] coherent fingerprint applied, the
//! Akamai script's own measurements come back human-shaped enough
//! to pass. We just wait for the script to finish its POST and
//! harvest the resulting `_abck` cookie.
//!
//! # Pass criteria
//!
//! `_abck` cookie is set AND the third segment is **not** `0`:
//!
//! ```text
//! _abck=ABCDEF~0~YA AAv...    ← invalid, sensor not yet POSTed
//! _abck=ABCDEF~-1~YA AAv...   ← valid, Akamai accepted us
//! _abck=ABCDEF~1~YA AAv...    ← under suspicion (treated as fail)
//! ```
//!
//! The `bm_sz` cookie (Akamai session) appearing in parallel is
//! corroborating but not sufficient: `bm_sz` lands on every visit,
//! valid `_abck` is the actual gate.

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

/// Default total wait budget. Akamai's sensor POST typically completes
/// within 3–6s after the script loads; 20s is a generous upper bound.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// Akamai Bot Manager interstitial solver.
pub struct AkamaiInterstitialSolver {
    max_wait_ms: u64,
}

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

impl AkamaiInterstitialSolver {
    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 `_abck` cookie value's validity flag.
///
/// Returns one of [`AbckValidity::Pending`] (no cookie or sensor
/// not yet POSTed), [`AbckValidity::Valid`] (Akamai accepted),
/// [`AbckValidity::Suspicious`] (under suspicion: Akamai is on
/// the fence and will likely escalate to a CAPTCHA challenge).
///
/// Pure function (no IO. Fully covered by the unit tests).
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::akamai_interstitial::{classify_abck, AbckValidity};
///
/// assert_eq!(classify_abck(""), AbckValidity::Pending);
/// assert_eq!(classify_abck("ABC~0~AAv"), AbckValidity::Pending);
/// assert_eq!(classify_abck("ABC~-1~AAv"), AbckValidity::Valid);
/// assert_eq!(classify_abck("ABC~1~AAv"), AbckValidity::Suspicious);
/// ```
pub fn classify_abck(value: &str) -> AbckValidity {
    if value.is_empty() {
        return AbckValidity::Pending;
    }
    let segments: Vec<&str> = value.split('~').collect();
    let Some(flag) = segments.get(1) else {
        return AbckValidity::Pending;
    };
    match flag.trim() {
        "0" => AbckValidity::Pending,
        "-1" => AbckValidity::Valid,
        // Any positive integer (1, 2, 3...) means Akamai is
        // suspicious of the session and is likely to escalate
        // to a CAPTCHA next request. Treat as not-yet-passing.
        s if s.parse::<i32>().map(|n| n > 0).unwrap_or(false) => AbckValidity::Suspicious,
        _ => AbckValidity::Pending,
    }
}

/// Validity classification of an `_abck` cookie value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbckValidity {
    /// No cookie yet, or sensor data not yet POSTed.
    Pending,
    /// Akamai accepted the session (third segment = `-1`).
    Valid,
    /// Akamai is suspicious (third segment > 0), next request
    /// likely escalates to a CAPTCHA challenge.
    Suspicious,
}

/// JS that probes the page for Akamai interstitial signals + the
/// `_abck` cookie value. Returns:
///   - `{ phase: "blocked" }`: explicit Akamai block page (rare;
///     usually an upstream WAF rule, not the interstitial)
///   - `{ phase: "interstitial" }`: sensor script present, no
///     valid cookie yet
///   - `{ phase: "passed", abck: "..." }`: `_abck` is set
const PHASE_PROBE_JS: &str = r#"
(() => {
    const title = (document.title || '').toLowerCase();
    const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
    if (title.includes('access denied') || title.includes('reference #') ||
        body.includes('reference #') || body.includes('access denied')) {
        return { phase: 'blocked' };
    }
    /* Surface the abck cookie value if present so the host classifier
       can decide. */
    let abck = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        if (t.startsWith('_abck=')) {
            abck = t.slice('_abck='.length);
            break;
        }
    }
    if (abck) return { phase: 'passed', abck };
    /* Detect interstitial, the script tag, the bmak global, or the
       canonical "Press & Hold" challenge that Akamai emits when its
       confidence is borderline. */
    const sensorPresent =
        !!document.querySelector('script[src*="akam"], script[src*="_bm/"], script[src*="_sec/"]') ||
        typeof window.bmak !== 'undefined' ||
        body.includes('press & hold') ||
        body.includes('press and hold');
    if (sensorPresent) return { phase: 'interstitial' };
    /* Nothing Akamai-shaped on the page, caller decides whether
       this is "passed (no cookie yet)" or "wrong vendor". */
    return { phase: 'unknown' };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    abck: String,
}

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Akamai is detected as DetectedCaptcha::Custom("akamai_bot_manager")
        // by the bundled community rule. We also accept generic
        // Custom(_) so any user-added Akamai-shaped rule routes here
        // before falling through to the slider/behavioral chain.
        matches!(
            kind,
            DetectedCaptcha::Custom(_) | DetectedCaptcha::MultiStepCaptcha
        )
    }

    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!("Akamai interstitial probe failed: {e}"))?;
            let probe: PhaseProbe = match raw.into_value() {
                Ok(p) => p,
                Err(_) => PhaseProbe {
                    phase: "interstitial".to_string(),
                    abck: String::new(),
                },
            };

            match probe.phase.as_str() {
                "passed" => match classify_abck(&probe.abck) {
                    AbckValidity::Valid => {
                        let cookies = crate::cookies::capture_from_page(page)
                            .await
                            .unwrap_or_default();
                        return Ok(CaptchaSolveResult {
                            solution: "akamai:_abck".into(),
                            confidence: 1.0,
                            method: SolveMethod::AutoPass,
                            time_ms: t0.elapsed().as_millis() as u64,
                            success: true,
                            screenshot: None,
                            cookies,
                            verified_outcome: None,
                        });
                    }
                    AbckValidity::Suspicious => {
                        // Akamai will probably challenge next request.
                        // We still surface the cookie so the caller
                        // can replay it and observe the escalation,
                        // but we report failure so the chain falls
                        // through to behavioural simulation if any
                        // is wired.
                        return Ok(CaptchaSolveResult::failure(
                            SolveMethod::AutoPass,
                            t0.elapsed().as_millis() as u64,
                        ));
                    }
                    AbckValidity::Pending => {
                        // Cookie set but sensor not yet POSTed 
                        // give it a moment.
                    }
                },
                "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() {
        let s = AkamaiInterstitialSolver::new();
        assert_eq!(s.max_wait_ms, 20_000);
    }

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

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

    #[test]
    fn supports_custom_kind() {
        let s = AkamaiInterstitialSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("akamai_bot_manager".into())));
        assert!(s.supports(&DetectedCaptcha::MultiStepCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::HCaptcha));
        assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
    }

    #[test]
    fn classify_abck_empty_is_pending() {
        assert_eq!(classify_abck(""), AbckValidity::Pending);
    }

    #[test]
    fn classify_abck_zero_flag_is_pending() {
        // Cookie set but sensor not yet accepted.
        assert_eq!(
            classify_abck("ABCDEFGH~0~YAAv12345~~"),
            AbckValidity::Pending
        );
    }

    #[test]
    fn classify_abck_minus_one_flag_is_valid() {
        // The "Akamai accepted us" state.
        assert_eq!(
            classify_abck("ABCDEFGH~-1~YAAv12345~~"),
            AbckValidity::Valid
        );
    }

    #[test]
    fn classify_abck_positive_flag_is_suspicious() {
        // Any positive integer means Akamai is on the fence and will
        // escalate to a CAPTCHA next request.
        for flag in ["1", "2", "3", "42", "999"] {
            let cookie = format!("ABCDEFGH~{flag}~YAAv12345~~");
            assert_eq!(
                classify_abck(&cookie),
                AbckValidity::Suspicious,
                "flag {flag} should be Suspicious"
            );
        }
    }

    #[test]
    fn classify_abck_garbage_is_pending() {
        // Defensive, unparseable cookie should never be reported
        // as Valid. The downside of mis-classifying garbage as
        // Pending is at most a longer wait; mis-classifying as
        // Valid would let the chain commit to a non-passing session.
        assert_eq!(classify_abck("malformed"), AbckValidity::Pending);
        assert_eq!(classify_abck("a~b~c"), AbckValidity::Pending);
        assert_eq!(classify_abck("~~"), AbckValidity::Pending);
    }

    #[test]
    fn classify_abck_segment_with_whitespace() {
        // Real cookies sometimes carry leading/trailing whitespace
        // around segments depending on how the server set them.
        assert_eq!(classify_abck("ABC~ -1 ~AAv"), AbckValidity::Valid);
        assert_eq!(classify_abck("ABC~ 0 ~AAv"), AbckValidity::Pending);
    }

    #[test]
    fn phase_probe_js_covers_documented_signals() {
        for needle in [
            "_abck=",
            "akam",
            "_bm/",
            "_sec/",
            "press & hold",
            "press and hold",
            "access denied",
            "reference #",
            "bmak",
        ] {
            assert!(
                PHASE_PROBE_JS.to_lowercase().contains(needle),
                "PHASE_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn phase_probe_js_returns_canonical_phases() {
        // Documented phases, if we add or rename one, the host
        // match arm in solve() must be updated in lockstep. This
        // pin catches a doc/runtime drift.
        for phase in ["blocked", "interstitial", "passed", "unknown"] {
            assert!(
                PHASE_PROBE_JS.contains(&format!("'{phase}'")),
                "PHASE_PROBE_JS must emit phase: {phase}"
            );
        }
    }
}