1use 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#[derive(Debug, Clone)]
18pub struct CaptchaChallenge {
19 pub kind: CaptchaKind,
20 pub site_key: Option<String>,
24 pub url: String,
26}
27
28#[derive(Debug, Clone)]
30pub struct CaptchaSolution {
31 pub token: String,
33 pub form_field: String,
36}
37
38pub(crate) type CaptchaSolver = dyn Fn(
47 CaptchaChallenge,
48 ) -> BoxFuture<'static, Result<CaptchaSolution, Box<dyn std::error::Error + Send + Sync>>>
49 + Send
50 + Sync;
51
52pub(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
67pub(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
122pub(crate) async fn inject_captcha_solution(
126 session: &SessionHandle,
127 solution: &CaptchaSolution,
128) -> Result<(), ImpervaError> {
129 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}