captchaforge 0.2.0

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Cross-origin iframe evaluation helpers.
//!
//! CAPTCHA providers (reCAPTCHA, hCaptcha, Turnstile) render their challenges
//! inside sandboxed cross-origin iframes.  JavaScript running in the parent
//! page cannot pierce these iframes via `contentDocument` or
//! `contentWindow.document` — doing so throws a `SecurityError`.
//!
//! This module uses the Chrome DevTools Protocol to evaluate expressions in
//! each frame's own execution context, which works regardless of origin.

use anyhow::{anyhow, Result};
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;

/// Evaluate `expression` in every frame of the page (main document + all
/// iframes) and return the deserialized results from every frame that
/// produced a valid value.
///
/// This is the robust replacement for parent-page JS that tries to walk
/// into `iframe.contentDocument`.
pub async fn evaluate_in_all_frames<T>(page: &Page, expression: &str) -> Result<Vec<T>>
where
    T: serde::de::DeserializeOwned,
{
    let frame_ids = page.frames().await?;
    let mut out = Vec::with_capacity(frame_ids.len());
    for fid in frame_ids {
        if let Some(ctx) = page.frame_execution_context(fid.clone()).await? {
            let params = EvaluateParams::builder()
                .expression(expression)
                .context_id(ctx)
                .build()
                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
            let eval = page.evaluate_expression(params).await?;
            if let Ok(v) = eval.into_value::<T>() {
                out.push(v);
            }
        }
    }
    Ok(out)
}

/// Evaluate `expression` in every frame and return the **first** result that
/// passes `filter`.  If no frame produces a matching result, `default` is
/// returned.
pub async fn evaluate_in_frames_first<T, F>(
    page: &Page,
    expression: &str,
    filter: F,
    default: T,
) -> Result<T>
where
    T: serde::de::DeserializeOwned + Clone,
    F: Fn(&T) -> bool,
{
    let all = evaluate_in_all_frames::<T>(page, expression).await?;
    Ok(all.into_iter().find(filter).unwrap_or(default))
}

/// Search every frame for a DOM element matching `selector` and return its
/// bounding-box centre coordinates **relative to the main viewport**.
///
/// For elements inside cross-origin iframes this sums the iframe's own
/// bounding box with the element's position inside the iframe so the
/// resulting coordinates are safe to pass to `Input.dispatchMouseEvent`.
pub async fn find_element_centre_in_frames(
    page: &Page,
    selector: &str,
) -> Result<Option<(f64, f64)>> {
    let frame_ids = page.frames().await?;
    let main_frame = page.mainframe().await?;

    // Build a map from frame URL → iframe offset on the main page.
    // We use URL because CDP gives us frame URLs but not a direct DOM↔frame
    // mapping.  This is sufficient for CAPTCHA iframes which have unique
    // URLs (google.com/recaptcha, challenges.cloudflare.com, etc.).
    let mut iframe_offsets: std::collections::HashMap<String, (f64, f64)> =
        std::collections::HashMap::new();
    if let Some(ref main) = main_frame {
        let js = r#"
            (function() {
                const out = [];
                for (const f of document.querySelectorAll('iframe')) {
                    const r = f.getBoundingClientRect();
                    out.push({ src: f.src, id: f.id, x: r.left, y: r.top });
                }
                return out;
            })()
        "#;
        if let Some(ctx) = page.frame_execution_context(main.clone()).await? {
            let params = EvaluateParams::builder()
                .expression(js)
                .context_id(ctx)
                .build()
                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
            let eval = page.evaluate_expression(params).await?;
            if let Ok(vals) = eval.into_value::<Vec<serde_json::Value>>() {
                for v in vals {
                    let key = v["src"].as_str().or_else(|| v["id"].as_str()).unwrap_or("");
                    if let (Some(x), Some(y)) = (v["x"].as_f64(), v["y"].as_f64()) {
                        iframe_offsets.insert(key.to_string(), (x, y));
                    }
                }
            }
        }
    }

    let escaped = selector.replace('\\', "\\\\").replace('\'', "\\'");
    let js = format!(
        r#"(function() {{
            const el = document.querySelector('{}');
            if (!el) return null;
            const r = el.getBoundingClientRect();
            return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href }};
        }})()"#,
        escaped
    );

    for fid in frame_ids {
        if let Some(ctx) = page.frame_execution_context(fid.clone()).await? {
            let params = EvaluateParams::builder()
                .expression(&js)
                .context_id(ctx)
                .build()
                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
            let eval = page.evaluate_expression(params).await?;
            if let Ok(val) = eval.into_value::<serde_json::Value>() {
                if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
                    let url = val["url"].as_str().unwrap_or("");
                    let (offset_x, offset_y) = if Some(fid) == main_frame.clone() {
                        (0.0, 0.0)
                    } else {
                        iframe_offsets.get(url).copied().unwrap_or((0.0, 0.0))
                    };
                    return Ok(Some((x + offset_x, y + offset_y)));
                }
            }
        }
    }
    Ok(None)
}

/// Check whether a CAPTCHA response token exists in **any** frame.
/// Used for post-solve verification when the provider may inject the token
/// into a hidden input in the main document or inside an iframe.
pub async fn verify_token_in_frames(
    page: &Page,
    token_input_name: &str,
) -> Result<bool> {
    let js = format!(
        r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
        token_input_name.replace('"', "\\\"")
    );
    let results = evaluate_in_all_frames::<bool>(page, &js).await?;
    Ok(results.into_iter().any(|v| v))
}

#[cfg(test)]
mod tests {
    #[test]
    fn selector_escaping_handles_quotes() {
        // Just verify the escape helper doesn't panic on edge-case selectors
        let sel = "[data-test='value']";
        let escaped = sel.replace('\\', "\\\\").replace('\'', "\\'");
        assert!(escaped.contains("\\'"));
    }

    #[test]
    fn token_name_escaping_handles_quotes() {
        let name = r#"g-recaptcha-response"#;
        let escaped = name.replace('"', "\\\"");
        assert!(!escaped.contains('"'));
    }
}