Skip to main content

cratefield_adapter_turnstile/
lib.rs

1//! `cratefield-adapter-turnstile`: the [`Captcha`] port over Cloudflare
2//! Turnstile `siteverify` (issue #7). Runs on the runtime's `HttpClient`
3//! port; the 5 s timeout is supplied by the runtime's [`Clock`].
4//!
5//! Fail-closed by default: a transport failure (or timeout) verifies as
6//! `{ ok: false, reason: "unavailable" }`. `.fail_open(true)` is for
7//! staging only. If the secret is absent, [`Turnstile::from_env`] returns
8//! `None` so the port is not provided at all — and `Harness::build` then
9//! refuses a production venture with `HumanForm` routes (issue #133).
10//!
11//! A bound adapter checks what it was bound to: a set of expected
12//! hostnames — an apex and its `www`, say — rejects responses from any
13//! other site (including a response that omits the hostname), and an
14//! expected action rejects a token minted for a different flow.
15//! [`Captcha::binding`] reports both, so the harness can tell "port
16//! present" from "verification configured".
17//!
18//! The siteverify form body is fully percent-encoded and the token is
19//! shape-checked before anything is sent: a malformed token verifies as
20//! `invalid-input-response` locally, with no request at all (issue #436).
21
22#![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
35/// `Captcha` over Turnstile siteverify.
36pub struct Turnstile {
37    http: Arc<dyn HttpClient>,
38    clock: Arc<dyn Clock>,
39    secret: String,
40    /// Empty means unbound; [`Turnstile::expected_hostname`] binds a
41    /// one-element vec, [`Turnstile::expected_hostnames`] a set.
42    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    /// `Some(...)` only when `TURNSTILE_SECRET` is set: an absent secret
64    /// means the port is not provided at all. `TURNSTILE_HOSTNAME` (a
65    /// comma-separated list of hostnames) and `TURNSTILE_ACTION` bind the
66    /// checks when present (issue #133: an unbound adapter cannot support a
67    /// production `HumanForm` route).
68    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    /// Verifies the response hostname against this expectation. A mismatch
81    /// — or a response that carries no hostname at all — fails the verdict
82    /// with `hostname-mismatch` (issue #133: absent is not "passed").
83    #[must_use]
84    pub fn expected_hostname(self, hostname: impl Into<String>) -> Self {
85        self.expected_hostnames([hostname.into()])
86    }
87
88    /// Verifies the response hostname against this set: any member passes,
89    /// and a mismatch — or a response that carries no hostname at all —
90    /// fails the verdict with `hostname-mismatch` (issue #710: a page
91    /// served on both the apex and `www` runs one widget for both hosts,
92    /// and siteverify reports whichever host the visitor used).
93    ///
94    /// Pass at least one hostname: an empty iterator leaves the adapter
95    /// unbound and accepting of every response, whereas the env path reads
96    /// "nothing usable" as *bound* so a malformed value keeps failing
97    /// closed. `Harness::build` rejects that unbound shape for a production
98    /// `HumanForm` route (`binding.hostname_bound` must be true), so the
99    /// mistake surfaces at build time rather than at the first verification.
100    ///
101    /// Members are matched exactly as given: nothing is trimmed and no
102    /// empty entry is dropped, so `["example.com", ""]` would match a
103    /// response carrying an empty hostname. The comma-separated
104    /// `TURNSTILE_HOSTNAME` path does trim and drop empties, so prefer the
105    /// env list or pass already-trimmed names.
106    #[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    /// Verifies the response `action` against this expectation, so a token
117    /// minted for another flow on the same site does not authorize this
118    /// one. Mismatch or absence fails the verdict with `action-mismatch`.
119    #[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    /// Transport failures verify as OK. **Staging only** — production is
126    /// fail-closed (architecture section 11).
127    #[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
145/// Splits `TURNSTILE_HOSTNAME` into the hostnames it lists: comma
146/// separated, whitespace trimmed (issue #710 — a page served on both the
147/// apex and `www` uses one widget for both hosts, and siteverify reports
148/// whichever host the visitor used). A value that lists nothing usable
149/// binds that raw value rather than nothing, so a malformed
150/// configuration keeps failing closed instead of silently unbound.
151fn 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
164/// Turnstile tokens are ASCII `[A-Za-z0-9._-]`, bounded in length; a
165/// value outside that shape is not a token this adapter will carry.
166const MAX_TOKEN_LEN: usize = 2048;
167
168/// True when `token` matches `[A-Za-z0-9._-]{1,2048}`. The check runs
169/// before anything is built or sent, so an attacker-supplied token is
170/// never passed on to siteverify at all.
171fn 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
181// Duplicated from the Stripe adapter (`crates/adapter-stripe`) rather than
182// shared: a dependency edge between two adapters (or a new public helper
183// in `cratefield-core`) costs more than a fifteen-line copy, so issue #436
184// chose the local copy.
185/// Percent-encodes for `application/x-www-form-urlencoded`: unreserved bytes
186/// pass through, everything else (space included, as `%20`) is escaped.
187fn 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
203/// Builds the siteverify form body with every field percent-encoded.
204/// `token` arrives from the untrusted request body and `remote_ip` from
205/// request headers, so an unencoded `&` or `=` would inject an extra
206/// parameter into the verification request (issue #436).
207fn 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        // A malformed token is refused before anything is built or sent.
236        // This refusal is unconditional: `fail_open` exists for transport
237        // and availability failures, not for a syntactically invalid
238        // token — a fail-open adapter must not wave one through to a
239        // provider that could be tricked by it (issue #436).
240        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        // Transport errors surface as the (fail-closed) verdict; the
269        // error itself is logged, never propagated to the module.
270        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        // A bound check that the provider did not answer is a mismatch:
293        // absent hostname/action means the widget was not bound as this
294        // deployment requires (issue #133).
295        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        // The injection the issue describes: `&` and `=` in the token (or
352        // the header-supplied IP) must arrive as data, not as structure.
353        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        // A doubled comma is a typo in the env value, not a hostname: the
400        // interior empty must not become a member that a response could
401        // match against (issue #710 made the list multi-valued, so an
402        // interior empty is now reachable at all).
403        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        // The same typo at the end of the list still leaves the two real
412        // hostnames bound, rather than degrading to the raw value.
413        assert_eq!(parse_hostnames("a.com,"), vec!["a.com".to_string()]);
414    }
415
416    // `from_env` itself cannot be exercised here: env mutation is unsafe in
417    // edition 2024, so `parse_hostnames` — the part that decides what a
418    // `TURNSTILE_HOSTNAME` value binds — is unit-tested directly.
419    #[test]
420    fn a_value_listing_nothing_usable_stays_bound() {
421        // The fail-closed property: an empty (or whitespace-only) value
422        // binds its raw value rather than nothing, so a misconfigured
423        // deployment still rejects every response.
424        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}