captchaforge 0.2.4

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Captured browser cookies — preserve a solved-captcha session
//! across page loads.
//!
//! When a captcha is solved, the upstream WAF / vendor typically
//! issues one or more cookies (`cf_clearance`, `_pxhd`, `datadome`,
//! etc.) that grant the browser a window of trusted access. Without
//! capturing + replaying these cookies, every navigation re-triggers
//! the captcha challenge.
//!
//! [`capture_from_page`] grabs every cookie from the live page after
//! a successful solve. [`apply_to_page`] re-installs them on a fresh
//! page so the next request rides the trusted session.
//!
//! The capture path uses CDP `Network.getAllCookies`; the apply path
//! uses CDP `Network.setCookie`. Both are wrapped here so consumers
//! don't need to import chromiumoxide CDP types directly.
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

/// A single captured browser cookie. Fields mirror the subset of
/// `Network.Cookie` that's relevant for replay.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct CapturedCookie {
    pub name: String,
    pub value: String,
    /// Cookie domain — leading-dot form preserved as-is.
    pub domain: String,
    pub path: String,
    /// Unix epoch seconds; `None` for session cookies that expire
    /// when the browser closes.
    pub expires: Option<i64>,
    pub secure: bool,
    pub http_only: bool,
    /// Cookie SameSite attribute as a lowercase string ("strict" /
    /// "lax" / "none"); `None` if unset.
    pub same_site: Option<String>,
}

impl CapturedCookie {
    /// True iff the cookie has an explicit expiry that has already
    /// passed. Session cookies (no expiry) are NOT considered
    /// expired by this helper — caller decides whether to replay
    /// them across browser restarts.
    pub fn is_expired_now(&self) -> bool {
        let Some(exp) = self.expires else {
            return false;
        };
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);
        exp <= now
    }

    /// Drop session cookies (no expiry) AND already-expired cookies
    /// from a slice. The remaining cookies are safe to persist
    /// across browser restarts and apply later.
    pub fn keep_persistent_alive(input: &[CapturedCookie]) -> Vec<CapturedCookie> {
        input
            .iter()
            .filter(|c| c.expires.is_some() && !c.is_expired_now())
            .cloned()
            .collect()
    }
}

/// Capture every cookie on `page`. Use after a successful captcha
/// solve to grab the WAF/vendor session token cookies for replay.
///
/// Returns an empty Vec if the page has no cookies (rather than
/// erroring) — a captcha solve isn't required to produce cookies on
/// every vendor.
pub async fn capture_from_page(page: &chromiumoxide::Page) -> anyhow::Result<Vec<CapturedCookie>> {
    use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;

    let resp = page.execute(GetCookiesParams::default()).await?;
    let mut out = Vec::with_capacity(resp.cookies.len());
    for c in &resp.cookies {
        out.push(CapturedCookie {
            name: c.name.clone(),
            value: c.value.clone(),
            domain: c.domain.clone(),
            path: c.path.clone(),
            expires: cdp_expiry_to_epoch(c.expires),
            secure: c.secure,
            http_only: c.http_only,
            same_site: c.same_site.as_ref().map(|s| {
                // SameSite enum's Debug form is the title-cased variant
                // ("Strict" / "Lax" / "None"); lowercase for stable wire
                // representation matching the HTTP `SameSite=` attribute.
                format!("{s:?}").to_lowercase()
            }),
        });
    }
    Ok(out)
}

/// Apply previously-[`capture_from_page`]-captured cookies to a
/// fresh `page`. Skips entries that have already expired; returns
/// the count of cookies actually installed.
///
/// Called early in the navigation flow (before the page that would
/// otherwise trigger captcha is loaded) so the WAF accepts the
/// trusted session.
pub async fn apply_to_page(
    page: &chromiumoxide::Page,
    cookies: &[CapturedCookie],
) -> anyhow::Result<usize> {
    use chromiumoxide::cdp::browser_protocol::network::{
        CookieParam, SetCookiesParams, TimeSinceEpoch,
    };

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);

    let mut to_install = Vec::with_capacity(cookies.len());
    for c in cookies {
        if let Some(exp) = c.expires {
            if exp <= now {
                continue; // skip expired
            }
        }
        let mut builder = CookieParam::builder()
            .name(c.name.clone())
            .value(c.value.clone())
            .domain(c.domain.clone())
            .path(c.path.clone())
            .secure(c.secure)
            .http_only(c.http_only);
        if let Some(exp) = c.expires {
            builder = builder.expires(TimeSinceEpoch::new(exp as f64));
        }
        let cookie = builder
            .build()
            .map_err(|e| anyhow::anyhow!("cookie builder: {e}"))?;
        to_install.push(cookie);
    }

    let count = to_install.len();
    if !to_install.is_empty() {
        page.execute(SetCookiesParams::new(to_install)).await?;
    }
    Ok(count)
}

/// Convert a CDP cookie expiry (`f64`, may be negative-1 sentinel)
/// to a Unix-epoch Option. CDP uses `-1` to indicate session cookie.
fn cdp_expiry_to_epoch(raw: f64) -> Option<i64> {
    if raw <= 0.0 {
        None
    } else {
        Some(raw as i64)
    }
}

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

    fn cookie(name: &str, expires: Option<i64>) -> CapturedCookie {
        CapturedCookie {
            name: name.into(),
            value: "v".into(),
            domain: ".example.com".into(),
            path: "/".into(),
            expires,
            secure: true,
            http_only: false,
            same_site: None,
        }
    }

    #[test]
    fn is_expired_now_true_for_past_expires() {
        let c = cookie("c", Some(1)); // 1970-01-01: definitely past
        assert!(c.is_expired_now());
    }

    #[test]
    fn is_expired_now_false_for_far_future_expires() {
        let c = cookie("c", Some(i64::MAX));
        assert!(!c.is_expired_now());
    }

    #[test]
    fn is_expired_now_false_for_session_cookie() {
        let c = cookie("c", None);
        assert!(!c.is_expired_now());
    }

    #[test]
    fn keep_persistent_alive_drops_session_cookies() {
        let cookies = vec![
            cookie("session", None),
            cookie("persistent", Some(i64::MAX)),
        ];
        let kept = CapturedCookie::keep_persistent_alive(&cookies);
        assert_eq!(kept.len(), 1);
        assert_eq!(kept[0].name, "persistent");
    }

    #[test]
    fn keep_persistent_alive_drops_expired_cookies() {
        let cookies = vec![cookie("expired", Some(1)), cookie("alive", Some(i64::MAX))];
        let kept = CapturedCookie::keep_persistent_alive(&cookies);
        assert_eq!(kept.len(), 1);
        assert_eq!(kept[0].name, "alive");
    }

    #[test]
    fn cdp_expiry_negative_one_becomes_none() {
        assert_eq!(cdp_expiry_to_epoch(-1.0), None);
    }

    #[test]
    fn cdp_expiry_zero_becomes_none() {
        assert_eq!(cdp_expiry_to_epoch(0.0), None);
    }

    #[test]
    fn cdp_expiry_positive_becomes_some_truncated() {
        assert_eq!(cdp_expiry_to_epoch(1234567890.7), Some(1234567890));
    }

    #[test]
    fn captured_cookie_serde_roundtrip() {
        let c = cookie("cf_clearance", Some(1234567890));
        let json = serde_json::to_string(&c).unwrap();
        let back: CapturedCookie = serde_json::from_str(&json).unwrap();
        assert_eq!(c, back);
    }
}