Skip to main content

captchaforge/
frame.rs

1//! Cross-origin iframe evaluation helpers.
2//!
3//! CAPTCHA providers (reCAPTCHA, hCaptcha, Turnstile) render their challenges
4//! inside sandboxed cross-origin iframes.  JavaScript running in the parent
5//! page cannot pierce these iframes via `contentDocument` or
6//! `contentWindow.document` — doing so throws a `SecurityError`.
7//!
8//! This module uses the Chrome DevTools Protocol to evaluate expressions in
9//! each frame's own execution context, which works regardless of origin.
10
11use anyhow::{anyhow, Result};
12use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
13use chromiumoxide::Page;
14
15/// Escape a Rust string so it is safe to embed in a JavaScript string literal
16/// surrounded by either single or double quotes.
17///
18/// Handles the following escapes:
19/// - `\`  → `\\`
20/// - `'`  → `\'`
21/// - `"`  → `\"`
22/// - `\n` → `\\n`
23/// - `\r` → `\\r`
24/// - `\t` → `\\t`
25/// - `\0` → `\\0`
26fn escape_js_string(s: &str) -> String {
27    let mut out = String::with_capacity(s.len());
28    for ch in s.chars() {
29        match ch {
30            '\\' => out.push_str("\\\\"),
31            '\'' => out.push_str("\\'"),
32            '"' => out.push_str("\\\""),
33            '\n' => out.push_str("\\n"),
34            '\r' => out.push_str("\\r"),
35            '\t' => out.push_str("\\t"),
36            '\0' => out.push_str("\\0"),
37            c => out.push(c),
38        }
39    }
40    out
41}
42
43/// Look up the iframe offset for a given URL and optional iframe index.
44///
45/// `iframe_offsets` is a Vec of `(idx, src, id, x, y)` tuples collected from
46/// the main frame in DOM order.  If `iframe_idx` is non-negative we prefer an
47/// exact index match to disambiguate duplicate URLs.
48fn lookup_iframe_offset(
49    iframe_offsets: &[(usize, String, String, f64, f64)],
50    url: &str,
51    iframe_idx: i64,
52) -> (f64, f64) {
53    if iframe_idx >= 0 {
54        iframe_offsets
55            .iter()
56            .find(|(idx, src, id, _, _)| *idx == iframe_idx as usize && (src == url || id == url))
57            .map(|(_, _, _, x, y)| (*x, *y))
58    } else {
59        iframe_offsets
60            .iter()
61            .find(|(_, src, id, _, _)| src == url || id == url)
62            .map(|(_, _, _, x, y)| (*x, *y))
63    }
64    .unwrap_or((0.0, 0.0))
65}
66
67/// Evaluate `expression` in every frame of the page (main document + all
68/// iframes) and return the deserialized results from every frame that
69/// produced a valid value.
70///
71/// This is the robust replacement for parent-page JS that tries to walk
72/// into `iframe.contentDocument`.
73///
74/// # Example
75///
76/// ```rust,no_run
77/// use captchaforge::frame::evaluate_in_all_frames;
78/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
79/// let titles: Vec<String> = evaluate_in_all_frames(page, "document.title").await?;
80/// # Ok(()) }
81/// ```
82pub async fn evaluate_in_all_frames<T>(page: &Page, expression: &str) -> Result<Vec<T>>
83where
84    T: serde::de::DeserializeOwned,
85{
86    let frame_ids = page.frames().await?;
87    let mut out = Vec::with_capacity(frame_ids.len());
88    for fid in frame_ids {
89        if let Some(ctx) = page.frame_execution_context(fid.clone()).await? {
90            let params = EvaluateParams::builder()
91                .expression(expression)
92                .context_id(ctx)
93                .build()
94                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
95            let eval = page.evaluate_expression(params).await?;
96            if let Ok(v) = eval.into_value::<T>() {
97                out.push(v);
98            }
99        }
100    }
101    Ok(out)
102}
103
104/// Evaluate `expression` in every frame and return the **first** result that
105/// passes `filter`.  If no frame produces a matching result, `default` is
106/// returned.
107pub async fn evaluate_in_frames_first<T, F>(
108    page: &Page,
109    expression: &str,
110    filter: F,
111    default: T,
112) -> Result<T>
113where
114    T: serde::de::DeserializeOwned + Clone,
115    F: Fn(&T) -> bool,
116{
117    let all = evaluate_in_all_frames::<T>(page, expression).await?;
118    Ok(all.into_iter().find(filter).unwrap_or(default))
119}
120
121/// Search every frame for a DOM element matching `selector` and return its
122/// bounding-box centre coordinates **relative to the main viewport**.
123///
124/// For elements inside cross-origin iframes this sums the iframe's own
125/// bounding box with the element's position inside the iframe so the
126/// resulting coordinates are safe to pass to `Input.dispatchMouseEvent`.
127///
128/// # Example
129///
130/// ```rust,no_run
131/// use captchaforge::frame::find_element_centre_in_frames;
132/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
133/// let centre = find_element_centre_in_frames(page, "#submit-btn").await?;
134/// if let Some((x, y)) = centre {
135///     // x, y are viewport-relative coordinates
136/// }
137/// # Ok(()) }
138/// ```
139pub async fn find_element_centre_in_frames(
140    page: &Page,
141    selector: &str,
142) -> Result<Option<(f64, f64)>> {
143    let frame_ids = page.frames().await?;
144    let main_frame = page.mainframe().await?;
145
146    // Build a Vec of iframe offsets keyed by the iframe's index in the
147    // DOM querySelectorAll result combined with src/id.  Using a Vec
148    // prevents HashMap collisions when two iframes share the same src.
149    let mut iframe_offsets: Vec<(usize, String, String, f64, f64)> = Vec::new();
150    if let Some(ref main) = main_frame {
151        let js = r#"
152            (function() {
153                const out = [];
154                const frames = document.querySelectorAll('iframe');
155                for (let i = 0; i < frames.length; i++) {
156                    const f = frames[i];
157                    const r = f.getBoundingClientRect();
158                    out.push({ idx: i, src: f.src, id: f.id, x: r.left, y: r.top });
159                }
160                return out;
161            })()
162        "#;
163        if let Some(ctx) = page.frame_execution_context(main.clone()).await? {
164            let params = EvaluateParams::builder()
165                .expression(js)
166                .context_id(ctx)
167                .build()
168                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
169            let eval = page.evaluate_expression(params).await?;
170            if let Ok(vals) = eval.into_value::<Vec<serde_json::Value>>() {
171                for v in vals {
172                    if let (Some(idx), Some(x), Some(y)) =
173                        (v["idx"].as_u64(), v["x"].as_f64(), v["y"].as_f64())
174                    {
175                        let src = v["src"].as_str().unwrap_or("").to_string();
176                        let id = v["id"].as_str().unwrap_or("").to_string();
177                        iframe_offsets.push((idx as usize, src, id, x, y));
178                    }
179                }
180            }
181        }
182    }
183
184    let escaped = escape_js_string(selector);
185    let js = format!(
186        r#"(function() {{
187            const el = document.querySelector('{}');
188            if (!el) return null;
189            const r = el.getBoundingClientRect();
190            let iframeIdx = -1;
191            try {{
192                const frames = window.parent.frames;
193                for (let i = 0; i < frames.length; i++) {{
194                    if (frames[i] === window) {{
195                        iframeIdx = i;
196                        break;
197                    }}
198                }}
199            }} catch (e) {{}}
200            return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href, iframeIdx: iframeIdx }};
201        }})()"#,
202        escaped
203    );
204
205    for fid in frame_ids {
206        if let Some(ctx) = page.frame_execution_context(fid.clone()).await? {
207            let params = EvaluateParams::builder()
208                .expression(&js)
209                .context_id(ctx)
210                .build()
211                .map_err(|e| anyhow!("EvaluateParams build failed: {e}"))?;
212            let eval = page.evaluate_expression(params).await?;
213            if let Ok(val) = eval.into_value::<serde_json::Value>() {
214                if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
215                    let url = val["url"].as_str().unwrap_or("");
216                    let iframe_idx = val["iframeIdx"].as_i64().unwrap_or(-1);
217                    let (offset_x, offset_y) = if Some(&fid) == main_frame.as_ref() {
218                        (0.0, 0.0)
219                    } else {
220                        lookup_iframe_offset(&iframe_offsets, url, iframe_idx)
221                    };
222                    return Ok(Some((x + offset_x, y + offset_y)));
223                }
224            }
225        }
226    }
227    Ok(None)
228}
229
230/// Check whether a CAPTCHA response token exists in **any** frame.
231/// Used for post-solve verification when the provider may inject the token
232/// into a hidden input in the main document or inside an iframe.
233///
234/// # Example
235///
236/// ```rust,no_run
237/// use captchaforge::frame::verify_token_in_frames;
238/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
239/// let found = verify_token_in_frames(page, "g-recaptcha-response").await?;
240/// assert!(found);
241/// # Ok(()) }
242/// ```
243pub async fn verify_token_in_frames(page: &Page, token_input_name: &str) -> Result<bool> {
244    let escaped = escape_js_string(token_input_name);
245    let js = format!(
246        r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
247        escaped
248    );
249    let results = evaluate_in_all_frames::<bool>(page, &js).await?;
250    Ok(results.into_iter().any(|v| v))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn escape_js_string_all_special_chars() {
259        let input = "\\'\"\n\r\t\0";
260        assert_eq!(escape_js_string(input), "\\\\\\\'\\\"\\n\\r\\t\\0");
261    }
262
263    #[test]
264    fn escape_js_string_backslash() {
265        assert_eq!(escape_js_string(r"\"), "\\\\");
266    }
267
268    #[test]
269    fn escape_js_string_single_quote() {
270        assert_eq!(escape_js_string("'"), "\\'");
271    }
272
273    #[test]
274    fn escape_js_string_double_quote() {
275        assert_eq!(escape_js_string("\""), "\\\"");
276    }
277
278    #[test]
279    fn escape_js_string_newline() {
280        assert_eq!(escape_js_string("a\nb"), "a\\nb");
281    }
282
283    #[test]
284    fn escape_js_string_carriage_return() {
285        assert_eq!(escape_js_string("a\rb"), "a\\rb");
286    }
287
288    #[test]
289    fn escape_js_string_tab() {
290        assert_eq!(escape_js_string("a\tb"), "a\\tb");
291    }
292
293    #[test]
294    fn escape_js_string_null_byte() {
295        assert_eq!(escape_js_string("a\0b"), "a\\0b");
296    }
297
298    #[test]
299    fn escape_js_string_mixed() {
300        let input = "line1\nline2\tcol\0end\\\"'";
301        assert_eq!(
302            escape_js_string(input),
303            "line1\\nline2\\tcol\\0end\\\\\\\"\\'"
304        );
305    }
306
307    #[test]
308    fn escape_js_string_no_special_chars() {
309        assert_eq!(escape_js_string("#simple-id"), "#simple-id");
310    }
311
312    #[test]
313    fn lookup_iframe_offset_by_index_and_url() {
314        let offsets = vec![
315            (0, "a.html".into(), "".into(), 10.0, 20.0),
316            (1, "b.html".into(), "".into(), 30.0, 40.0),
317        ];
318        assert_eq!(lookup_iframe_offset(&offsets, "a.html", 0), (10.0, 20.0));
319        assert_eq!(lookup_iframe_offset(&offsets, "b.html", 1), (30.0, 40.0));
320    }
321
322    #[test]
323    fn lookup_iframe_offset_fallback_when_index_missing() {
324        let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
325        assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
326    }
327
328    #[test]
329    fn lookup_iframe_offset_disambiguates_duplicate_src() {
330        let offsets = vec![
331            (0, "same.html".into(), "".into(), 10.0, 20.0),
332            (1, "same.html".into(), "".into(), 30.0, 40.0),
333        ];
334        // With index we can tell them apart.
335        assert_eq!(lookup_iframe_offset(&offsets, "same.html", 0), (10.0, 20.0));
336        assert_eq!(lookup_iframe_offset(&offsets, "same.html", 1), (30.0, 40.0));
337        // Without index, fallback to first match.
338        assert_eq!(
339            lookup_iframe_offset(&offsets, "same.html", -1),
340            (10.0, 20.0)
341        );
342    }
343
344    #[test]
345    fn lookup_iframe_offset_empty_src_and_id() {
346        let offsets = vec![
347            (0, "".into(), "".into(), 5.0, 5.0),
348            (1, "".into(), "".into(), 15.0, 15.0),
349        ];
350        assert_eq!(lookup_iframe_offset(&offsets, "", 0), (5.0, 5.0));
351        assert_eq!(lookup_iframe_offset(&offsets, "", 1), (15.0, 15.0));
352    }
353
354    #[test]
355    fn lookup_iframe_offset_no_match() {
356        let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
357        assert_eq!(
358            lookup_iframe_offset(&offsets, "missing.html", -1),
359            (0.0, 0.0)
360        );
361    }
362
363    #[test]
364    fn find_element_js_contains_query_selector() {
365        let selector = "#btn";
366        let escaped = escape_js_string(selector);
367        let js = format!(
368            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 }}; }})()"#,
369            escaped
370        );
371        assert!(js.contains("document.querySelector"));
372        assert!(js.contains("getBoundingClientRect"));
373    }
374
375    #[test]
376    fn verify_token_js_contains_input_selector() {
377        let name = "g-recaptcha-response";
378        let escaped = escape_js_string(name);
379        let js = format!(
380            r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
381            escaped
382        );
383        assert!(js.contains("input[name="));
384        assert!(js.contains("value]:not([value=\"\"])"));
385    }
386
387    #[test]
388    fn verify_token_escapes_quotes() {
389        let name = r#"token"value"#;
390        let escaped = escape_js_string(name);
391        assert!(escaped.contains("\\\""));
392        for (i, ch) in escaped.char_indices() {
393            if ch == '"' {
394                assert!(
395                    i > 0 && escaped.as_bytes()[i - 1] == b'\\',
396                    "quote at {} not escaped",
397                    i
398                );
399            }
400        }
401    }
402
403    #[test]
404    fn verify_token_escapes_null_and_newline() {
405        let name = "token\0value\n";
406        let escaped = escape_js_string(name);
407        assert!(escaped.contains("\\0"));
408        assert!(escaped.contains("\\n"));
409        assert!(!escaped.contains('\0'));
410        assert!(!escaped.contains('\n'));
411    }
412}