Skip to main content

zendriver_imperva/
captcha.rs

1//! CAPTCHA escalation path: types passed to / returned from the
2//! caller-supplied solver, plus the small CDP helpers that probe the page
3//! for the embed's site key and inject the solver's response back into the
4//! form field. Kept out of `bypass.rs` so that file stays focused on the
5//! poll loop.
6
7use std::sync::Arc;
8
9use futures::future::BoxFuture;
10use serde_json::json;
11use zendriver_transport::SessionHandle;
12
13use crate::detection::CaptchaKind;
14use crate::error::ImpervaError;
15
16/// CAPTCHA escalation handed to a user-supplied solver.
17#[derive(Debug, Clone)]
18pub struct CaptchaChallenge {
19    pub kind: CaptchaKind,
20    /// Site key extracted from the embed (hCaptcha / reCAPTCHA). `None`
21    /// if the kind is `ImpervaNative` or `Unknown`, or if the probe found
22    /// no matching `data-sitekey` attribute on the page.
23    pub site_key: Option<String>,
24    /// URL of the page presenting the CAPTCHA.
25    pub url: String,
26}
27
28/// Token returned by a user-supplied CAPTCHA solver.
29#[derive(Debug, Clone)]
30pub struct CaptchaSolution {
31    /// Verification token issued by the solver service.
32    pub token: String,
33    /// DOM field where the token must be injected for the page to accept it
34    /// (e.g. `"h-captcha-response"`, `"g-recaptcha-response"`).
35    pub form_field: String,
36}
37
38/// Erased solver closure: `Fn(CaptchaChallenge) -> Future<Result<...>>` with
39/// `Send + Sync + 'static`, type-erased so [`ImpervaBypass`] can store it
40/// behind an `Arc<dyn ...>` without leaking the closure's generic parameters
41/// through the struct. The type alias spans four lines because dyn-trait
42/// aliases over async closures are simply verbose in Rust 2024 — there's
43/// no shorter form short of introducing a sealed marker trait.
44///
45/// [`ImpervaBypass`]: crate::bypass::ImpervaBypass
46pub(crate) type CaptchaSolver = dyn Fn(
47        CaptchaChallenge,
48    ) -> BoxFuture<'static, Result<CaptchaSolution, Box<dyn std::error::Error + Send + Sync>>>
49    + Send
50    + Sync;
51
52/// Convenience: wrap a typed closure into the `Arc<dyn CaptchaSolver>`
53/// shape stored on [`ImpervaBypass`].
54///
55/// [`ImpervaBypass`]: crate::bypass::ImpervaBypass
56pub(crate) fn arc_solver<F, Fut>(f: F) -> Arc<CaptchaSolver>
57where
58    F: Fn(CaptchaChallenge) -> Fut + Send + Sync + 'static,
59    Fut: std::future::Future<
60            Output = Result<CaptchaSolution, Box<dyn std::error::Error + Send + Sync>>,
61        > + Send
62        + 'static,
63{
64    Arc::new(move |challenge| Box::pin(f(challenge)))
65}
66
67/// Extract a CAPTCHA site key from the current page via a small inline JS
68/// probe. Returns `(None, location.href)` if no recognizable embed is
69/// present for the given `kind`.
70pub(crate) async fn extract_captcha_site_key(
71    session: &SessionHandle,
72    kind: CaptchaKind,
73) -> Result<(Option<String>, String), ImpervaError> {
74    const PROBE_JS: &str = r#"
75    (function () {
76        function findKey(selector, attr) {
77            var el = document.querySelector(selector);
78            return el ? el.getAttribute(attr) : null;
79        }
80        var hcap =
81            findKey(".h-captcha", "data-sitekey") ||
82            findKey("[data-hcaptcha-sitekey]", "data-hcaptcha-sitekey");
83        var rcap =
84            findKey(".g-recaptcha", "data-sitekey") ||
85            findKey("[data-recaptcha-sitekey]", "data-recaptcha-sitekey");
86        return { hcap: hcap, rcap: rcap, url: location.href };
87    })()
88    "#;
89
90    let res = session
91        .call(
92            "Runtime.evaluate",
93            json!({
94                "expression": PROBE_JS,
95                "returnByValue": true,
96            }),
97        )
98        .await?;
99    let value = res
100        .get("result")
101        .and_then(|r| r.get("value"))
102        .cloned()
103        .unwrap_or(serde_json::Value::Null);
104
105    #[derive(serde::Deserialize)]
106    struct Probe {
107        hcap: Option<String>,
108        rcap: Option<String>,
109        url: String,
110    }
111    let probe: Probe = serde_json::from_value(value)
112        .map_err(|e| ImpervaError::JsError(format!("invalid captcha probe payload: {e}")))?;
113
114    let site_key = match kind {
115        CaptchaKind::HCaptcha => probe.hcap,
116        CaptchaKind::Recaptcha => probe.rcap,
117        CaptchaKind::ImpervaNative | CaptchaKind::Unknown => None,
118    };
119    Ok((site_key, probe.url))
120}
121
122/// Inject a CAPTCHA solver token into the named form field via
123/// `Runtime.evaluate`. Creates a hidden `<textarea>` with the right
124/// name+id if no matching field exists.
125pub(crate) async fn inject_captcha_solution(
126    session: &SessionHandle,
127    solution: &CaptchaSolution,
128) -> Result<(), ImpervaError> {
129    // Escape `\` first, then `"` — order matters; reversing it would
130    // double-escape the backslashes inserted by the quote pass.
131    let name = solution
132        .form_field
133        .replace('\\', "\\\\")
134        .replace('"', "\\\"");
135    let script = format!(
136        r#"
137        (function () {{
138            var field = document.querySelector('[name="{name}"]')
139                || document.getElementById("{name}");
140            if (!field) {{
141                var t = document.createElement("textarea");
142                t.name = "{name}";
143                t.id = "{name}";
144                t.style.display = "none";
145                document.body.appendChild(t);
146                field = t;
147            }}
148            field.value = {token};
149            field.dispatchEvent(new Event("change", {{ bubbles: true }}));
150            return true;
151        }})()
152        "#,
153        name = name,
154        token = serde_json::Value::String(solution.token.clone()),
155    );
156
157    let res = session
158        .call(
159            "Runtime.evaluate",
160            json!({
161                "expression": script,
162                "returnByValue": true,
163            }),
164        )
165        .await?;
166    if let Some(details) = res.get("exceptionDetails") {
167        let msg = details
168            .get("exception")
169            .and_then(|e| e.get("description"))
170            .and_then(|d| d.as_str())
171            .unwrap_or("unknown")
172            .to_string();
173        return Err(ImpervaError::JsError(msg));
174    }
175    Ok(())
176}
177
178#[cfg(test)]
179#[allow(clippy::panic, clippy::unwrap_used)]
180mod tests {
181    use super::*;
182    use zendriver_transport::testing::MockConnection;
183
184    #[tokio::test]
185    async fn extract_returns_none_when_neither_sitekey_attr_present() {
186        let (mut mock, conn) = MockConnection::pair();
187        let sess = SessionHandle::new(conn.clone(), "S1");
188
189        let fut = tokio::spawn({
190            let s = sess.clone();
191            async move { extract_captcha_site_key(&s, CaptchaKind::HCaptcha).await }
192        });
193
194        let id = mock.expect_cmd("Runtime.evaluate").await;
195        mock.reply(
196            id,
197            json!({
198                "result": {
199                    "type": "object",
200                    "value": {
201                        "hcap": null,
202                        "rcap": null,
203                        "url": "https://example.com/x",
204                    }
205                }
206            }),
207        )
208        .await;
209
210        let (site_key, url) = fut.await.unwrap().unwrap();
211        assert!(site_key.is_none(), "no .h-captcha[data-sitekey] → None");
212        assert_eq!(url, "https://example.com/x");
213        conn.shutdown();
214    }
215
216    #[tokio::test]
217    async fn extract_uses_rcap_for_recaptcha_kind() {
218        let (mut mock, conn) = MockConnection::pair();
219        let sess = SessionHandle::new(conn.clone(), "S1");
220
221        let fut = tokio::spawn({
222            let s = sess.clone();
223            async move { extract_captcha_site_key(&s, CaptchaKind::Recaptcha).await }
224        });
225
226        let id = mock.expect_cmd("Runtime.evaluate").await;
227        mock.reply(
228            id,
229            json!({
230                "result": {
231                    "type": "object",
232                    "value": {
233                        "hcap": "IGNORED",
234                        "rcap": "RKEY",
235                        "url": "https://x.com/",
236                    }
237                }
238            }),
239        )
240        .await;
241
242        let (site_key, _) = fut.await.unwrap().unwrap();
243        assert_eq!(site_key.as_deref(), Some("RKEY"));
244        conn.shutdown();
245    }
246
247    #[tokio::test]
248    async fn extract_returns_none_for_imperva_native_kind() {
249        let (mut mock, conn) = MockConnection::pair();
250        let sess = SessionHandle::new(conn.clone(), "S1");
251
252        let fut = tokio::spawn({
253            let s = sess.clone();
254            async move { extract_captcha_site_key(&s, CaptchaKind::ImpervaNative).await }
255        });
256
257        let id = mock.expect_cmd("Runtime.evaluate").await;
258        mock.reply(
259            id,
260            json!({
261                "result": {
262                    "type": "object",
263                    "value": {
264                        "hcap": "WOULD_IGNORE",
265                        "rcap": "WOULD_IGNORE",
266                        "url": "https://x.com/",
267                    }
268                }
269            }),
270        )
271        .await;
272
273        let (site_key, _) = fut.await.unwrap().unwrap();
274        assert!(
275            site_key.is_none(),
276            "ImpervaNative kind never extracts a site_key"
277        );
278        conn.shutdown();
279    }
280
281    #[tokio::test]
282    async fn inject_escapes_backslash_and_quote_in_form_field() {
283        let (mut mock, conn) = MockConnection::pair();
284        let sess = SessionHandle::new(conn.clone(), "S1");
285
286        let solution = CaptchaSolution {
287            token: "TOK".into(),
288            form_field: r#"a\b"c"#.into(),
289        };
290
291        let fut = tokio::spawn({
292            let s = sess.clone();
293            async move { inject_captcha_solution(&s, &solution).await }
294        });
295
296        let id = mock.expect_cmd("Runtime.evaluate").await;
297        let sent = mock.last_sent();
298        let script = sent["params"]["expression"].as_str().unwrap();
299        assert!(
300            script.contains(r#"a\\b\"c"#),
301            "form_field must have \\ and \" both escaped; script was: {script}"
302        );
303        mock.reply(
304            id,
305            json!({ "result": { "type": "boolean", "value": true } }),
306        )
307        .await;
308
309        fut.await.unwrap().unwrap();
310        conn.shutdown();
311    }
312
313    #[tokio::test]
314    async fn inject_returns_jserror_when_evaluator_raises() {
315        let (mut mock, conn) = MockConnection::pair();
316        let sess = SessionHandle::new(conn.clone(), "S1");
317
318        let solution = CaptchaSolution {
319            token: "TOK".into(),
320            form_field: "h-captcha-response".into(),
321        };
322
323        let fut = tokio::spawn({
324            let s = sess.clone();
325            async move { inject_captcha_solution(&s, &solution).await }
326        });
327
328        let id = mock.expect_cmd("Runtime.evaluate").await;
329        mock.reply(
330            id,
331            json!({
332                "result": { "type": "undefined" },
333                "exceptionDetails": {
334                    "exception": { "description": "TypeError: nope" }
335                }
336            }),
337        )
338        .await;
339
340        let err = fut.await.unwrap().unwrap_err();
341        assert!(matches!(err, ImpervaError::JsError(s) if s.contains("TypeError")));
342        conn.shutdown();
343    }
344}