cratefield_adapter_turnstile/
lib.rs1#![doc = include_str!("../README.md")]
23#![forbid(unsafe_code)]
24
25use async_trait::async_trait;
26use bytes::Bytes;
27use cratefield_core::{Captcha, CaptchaBinding, CaptchaError, Clock, HttpClient, Verdict, timeout};
28use http::Request;
29use std::sync::Arc;
30use std::time::Duration;
31
32const SITEVERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
33const VERIFY_TIMEOUT: Duration = Duration::from_secs(5);
34
35pub struct Turnstile {
37 http: Arc<dyn HttpClient>,
38 clock: Arc<dyn Clock>,
39 secret: String,
40 expected_hostnames: Vec<String>,
43 expected_action: Option<String>,
44 fail_open: bool,
45}
46
47impl Turnstile {
48 pub fn new(
49 http: Arc<dyn HttpClient>,
50 clock: Arc<dyn Clock>,
51 secret: impl Into<String>,
52 ) -> Self {
53 Self {
54 http,
55 clock,
56 secret: secret.into(),
57 expected_hostnames: Vec::new(),
58 expected_action: None,
59 fail_open: false,
60 }
61 }
62
63 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Option<Self> {
69 let secret = std::env::var("TURNSTILE_SECRET").ok()?;
70 let mut turnstile = Self::new(http, clock, secret);
71 if let Ok(hostnames) = std::env::var("TURNSTILE_HOSTNAME") {
72 turnstile = turnstile.expected_hostnames(parse_hostnames(&hostnames));
73 }
74 if let Ok(action) = std::env::var("TURNSTILE_ACTION") {
75 turnstile = turnstile.expected_action(action);
76 }
77 Some(turnstile)
78 }
79
80 #[must_use]
84 pub fn expected_hostname(self, hostname: impl Into<String>) -> Self {
85 self.expected_hostnames([hostname.into()])
86 }
87
88 #[must_use]
107 pub fn expected_hostnames<I, S>(mut self, hostnames: I) -> Self
108 where
109 I: IntoIterator<Item = S>,
110 S: Into<String>,
111 {
112 self.expected_hostnames = hostnames.into_iter().map(Into::into).collect();
113 self
114 }
115
116 #[must_use]
120 pub fn expected_action(mut self, action: impl Into<String>) -> Self {
121 self.expected_action = Some(action.into());
122 self
123 }
124
125 #[must_use]
128 pub fn fail_open(mut self, fail_open: bool) -> Self {
129 self.fail_open = fail_open;
130 self
131 }
132
133 fn transport_verdict(&self) -> Verdict {
134 Verdict {
135 ok: self.fail_open,
136 reason: if self.fail_open {
137 None
138 } else {
139 Some("unavailable".to_string())
140 },
141 }
142 }
143}
144
145fn parse_hostnames(value: &str) -> Vec<String> {
152 let listed: Vec<String> = value
153 .split(',')
154 .map(str::trim)
155 .filter(|hostname| !hostname.is_empty())
156 .map(str::to_string)
157 .collect();
158 if listed.is_empty() {
159 return vec![value.to_string()];
160 }
161 listed
162}
163
164const MAX_TOKEN_LEN: usize = 2048;
167
168fn is_plausible_token(token: &str) -> bool {
172 let len = token.len();
173 if len == 0 || len > MAX_TOKEN_LEN {
174 return false;
175 }
176 token
177 .bytes()
178 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
179}
180
181fn percent_encode(input: &str) -> String {
188 let mut out = String::with_capacity(input.len());
189 for &byte in input.as_bytes() {
190 match byte {
191 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
192 out.push(byte as char);
193 }
194 other => {
195 use std::fmt::Write as _;
196 let _ = write!(out, "%{other:02X}");
197 }
198 }
199 }
200 out
201}
202
203fn encode_form(secret: &str, token: &str, remote_ip: Option<&str>) -> String {
208 let mut form = format!(
209 "secret={}&response={}",
210 percent_encode(secret),
211 percent_encode(token)
212 );
213 if let Some(ip) = remote_ip {
214 form.push_str("&remoteip=");
215 form.push_str(&percent_encode(ip));
216 }
217 form
218}
219
220#[derive(serde::Deserialize)]
221struct SiteverifyResponse {
222 #[serde(default)]
223 success: bool,
224 #[serde(rename = "error-codes", default)]
225 error_codes: Vec<String>,
226 #[serde(default)]
227 hostname: Option<String>,
228 #[serde(default)]
229 action: Option<String>,
230}
231
232#[async_trait]
233impl Captcha for Turnstile {
234 async fn verify(&self, token: &str, remote_ip: Option<&str>) -> Result<Verdict, CaptchaError> {
235 if !is_plausible_token(token) {
241 tracing::warn!("refusing malformed turnstile token");
242 return Ok(Verdict {
243 ok: false,
244 reason: Some("invalid-input-response".to_string()),
245 });
246 }
247 let form = encode_form(&self.secret, token, remote_ip);
248 let request = Request::builder()
249 .method(http::Method::POST)
250 .uri(SITEVERIFY_URL)
251 .header(
252 http::header::CONTENT_TYPE,
253 "application/x-www-form-urlencoded",
254 )
255 .body(Bytes::from(form))
256 .map_err(|err| CaptchaError::Transport(err.to_string()))?;
257
258 let http = Arc::clone(&self.http);
259 let Some(response) = timeout(
260 self.clock.as_ref(),
261 async move { http.send(request).await },
262 VERIFY_TIMEOUT,
263 )
264 .await
265 else {
266 return Ok(self.transport_verdict());
267 };
268 let Ok(response) = response else {
271 tracing::warn!("siteverify transport failure");
272 return Ok(self.transport_verdict());
273 };
274
275 let status = response.status();
276 let body = String::from_utf8_lossy(response.body()).to_string();
277 if !status.is_success() {
278 tracing::warn!(status = %status, "siteverify returned non-success");
279 return Ok(self.transport_verdict());
280 }
281
282 let parsed: SiteverifyResponse =
283 serde_json::from_str(&body).map_err(|err| CaptchaError::Transport(err.to_string()))?;
284
285 if !parsed.success {
286 return Ok(Verdict {
287 ok: false,
288 reason: parsed.error_codes.into_iter().next(),
289 });
290 }
291
292 if !self.expected_hostnames.is_empty()
296 && parsed.hostname.as_deref().is_none_or(|actual| {
297 !self
298 .expected_hostnames
299 .iter()
300 .any(|expected| expected == actual)
301 })
302 {
303 return Ok(Verdict {
304 ok: false,
305 reason: Some("hostname-mismatch".to_string()),
306 });
307 }
308
309 if let Some(expected) = &self.expected_action
310 && parsed
311 .action
312 .as_deref()
313 .is_none_or(|actual| actual != expected)
314 {
315 return Ok(Verdict {
316 ok: false,
317 reason: Some("action-mismatch".to_string()),
318 });
319 }
320
321 Ok(Verdict {
322 ok: true,
323 reason: None,
324 })
325 }
326
327 fn binding(&self) -> Option<CaptchaBinding> {
328 Some(CaptchaBinding {
329 hostname_bound: !self.expected_hostnames.is_empty(),
330 action_bound: self.expected_action.is_some(),
331 fail_open: self.fail_open,
332 })
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn percent_encode_passes_unreserved_bytes_and_escapes_the_rest() {
342 assert_eq!(percent_encode("abcXYZ019-_.~"), "abcXYZ019-_.~");
343 assert_eq!(percent_encode("a b"), "a%20b");
344 assert_eq!(percent_encode("secret&x=1"), "secret%26x%3D1");
345 assert_eq!(percent_encode("café"), "caf%C3%A9");
346 assert_eq!(percent_encode(""), "");
347 }
348
349 #[test]
350 fn a_hostile_token_and_ip_cannot_add_a_form_key() {
351 let body = encode_form(
354 "secret-value",
355 "abc&sitekey=evil&idempotency_key=x",
356 Some("1.2.3.4&foo=bar"),
357 );
358 let mut keys: Vec<&str> = body
359 .split('&')
360 .map(|pair| pair.split('=').next().expect("a pair always has a key"))
361 .collect();
362 keys.sort_unstable();
363 assert_eq!(keys, vec!["remoteip", "response", "secret"]);
364 assert!(body.contains("response=abc%26sitekey%3Devil%26idempotency_key%3Dx"));
365 assert!(body.contains("remoteip=1.2.3.4%26foo%3Dbar"));
366 }
367
368 #[test]
369 fn the_secret_is_encoded_too() {
370 assert_eq!(
371 encode_form("s&ecret=1", "tok", None),
372 "secret=s%26ecret%3D1&response=tok"
373 );
374 assert_eq!(
375 encode_form("secret-value", "tok", None),
376 "secret=secret-value&response=tok"
377 );
378 }
379
380 #[test]
381 fn parse_hostnames_splits_a_comma_list_and_trims() {
382 assert_eq!(
383 parse_hostnames("a.com, www.a.com"),
384 vec!["a.com".to_string(), "www.a.com".to_string()]
385 );
386 assert_eq!(
387 parse_hostnames(" a.com , www.a.com "),
388 vec!["a.com".to_string(), "www.a.com".to_string()]
389 );
390 }
391
392 #[test]
393 fn parse_hostnames_takes_a_single_value_whole() {
394 assert_eq!(parse_hostnames("a.com"), vec!["a.com".to_string()]);
395 }
396
397 #[test]
398 fn parse_hostnames_drops_an_empty_element_between_two_hostnames() {
399 assert_eq!(
404 parse_hostnames("a.com,,b.com"),
405 vec!["a.com".to_string(), "b.com".to_string()]
406 );
407 }
408
409 #[test]
410 fn parse_hostnames_drops_an_empty_tail_from_a_trailing_comma() {
411 assert_eq!(parse_hostnames("a.com,"), vec!["a.com".to_string()]);
414 }
415
416 #[test]
420 fn a_value_listing_nothing_usable_stays_bound() {
421 assert_eq!(parse_hostnames(""), vec![String::new()]);
425 assert_eq!(parse_hostnames(" "), vec![" ".to_string()]);
426 assert_eq!(parse_hostnames(","), vec![",".to_string()]);
427 }
428
429 #[test]
430 fn tokens_on_the_plausible_shape_pass_the_check() {
431 assert!(is_plausible_token("0.z8xQa_7-bBcCdDeE"));
432 let exactly_2048 = "a".repeat(MAX_TOKEN_LEN);
433 assert!(is_plausible_token(&exactly_2048));
434 }
435
436 #[test]
437 fn tokens_off_the_plausible_shape_fail_the_check() {
438 assert!(!is_plausible_token(""));
439 assert!(!is_plausible_token("bad&token"));
440 assert!(!is_plausible_token("bad token"));
441 assert!(!is_plausible_token("bad;token"));
442 assert!(!is_plausible_token("bad/token"));
443 assert!(!is_plausible_token("héllo"));
444 let one_past_the_ceiling = "a".repeat(MAX_TOKEN_LEN + 1);
445 assert!(!is_plausible_token(&one_past_the_ceiling));
446 }
447}