From d56a07193c905fb356bc49507de3556c89c86caf Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 09:30:57 +0200 Subject: [PATCH 01/18] Remove plain http support There is no way to point the client at a proxy and no way to change the destination because https://challenges.cloudflare.com/turnstile/v0/siteverify is hardcoded. --- README.md | 4 ++-- src/connector.rs | 12 +++++++++--- src/lib.rs | 12 ++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3a13198..6a10241 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ This will enable the `idempotency_key` field on the [`SiteVerifyRequest`](struct ### TLS -**Note**: not enabling any TLS feature is supported for use behind a proxy; -Turnstile's API is HTTPS only. +**Note**: Turnstile's API is HTTPS only, so exactly one TLS feature must be enabled. +Building without a TLS backend is a compile error. **Note**: this TLS code was taken from [twilight-http](https://github.com/twilight-rs/twilight/tree/main/twilight-http) in accordance with its license. diff --git a/src/connector.rs b/src/connector.rs index 80d1575..66f669d 100644 --- a/src/connector.rs +++ b/src/connector.rs @@ -43,20 +43,22 @@ pub fn create() -> Connector { #[cfg(feature = "hickory")] let mut connector = hyper_hickory::TokioHickoryResolver::default().into_http_connector(); + // Allow the `https` scheme through to the TLS connector that wraps this one. + // A TLS backend is guaranteed to be present: see the `compile_error!` in lib.rs. connector.enforce_http(false); #[cfg(feature = "rustls-native-roots")] let connector = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots() .expect("no native root certificates found") - .https_or_http() + .https_only() .enable_http1() .enable_http2() .wrap_connector(connector); #[cfg(all(feature = "rustls-webpki-roots", not(feature = "rustls-native-roots")))] let connector = hyper_rustls::HttpsConnectorBuilder::new() .with_webpki_roots() - .https_or_http() + .https_only() .enable_http1() .enable_http2() .wrap_connector(connector); @@ -65,7 +67,11 @@ pub fn create() -> Connector { not(feature = "rustls-native-roots"), not(feature = "rustls-webpki-roots") ))] - let connector = hyper_tls::HttpsConnector::new_with_connector(connector); + let connector = { + let mut connector = hyper_tls::HttpsConnector::new_with_connector(connector); + connector.https_only(true); + connector + }; connector } diff --git a/src/lib.rs b/src/lib.rs index 12fe8bb..dc98245 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,6 +146,18 @@ pub fn generate_idempotency_key() -> Option { Some(uuid::Uuid::new_v4()) } +// Turnstile's API is HTTPS only. Without a TLS backend the client would send the +// secret key over an unencrypted connection, so refuse to build instead. +#[cfg(not(any( + feature = "native-tls", + feature = "rustls-native-roots", + feature = "rustls-webpki-roots" +)))] +compile_error!( + r#"A TLS backend is required: enable exactly one of "rustls-native-roots" (the default), "rustls-webpki-roots" or "native-tls". +Turnstile's API is HTTPS only, and without TLS the secret key would be sent in cleartext."# +); + // Some features are mutually exclusive. This is documented in the readme, but also gives a compile-time error #[cfg(all(feature = "native-tls", feature = "rustls-native-roots"))] compile_error!( From 28780e740eb747dcad513a588cd87704eee6024a Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 09:51:42 +0200 Subject: [PATCH 02/18] Fix misspelling of "remoteip" parameter in SiteVerifyRequest It was named/renamed to remote_ip which is not what cloudflare expects. Also add skip_serializing_if for Option::is_none, so None/nil fields are skipped in the json payload. Also drop the `secret` field, it is now populated from the turnstile client. See https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ --- Cargo.toml | 2 +- src/lib.rs | 34 +++++++++++++++++++++------------- src/test.rs | 39 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6379ca1..bc694f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ hyper-rustls = { default-features = false, optional = true, features = ["http1", hyper-tls = { default-features = false, optional = true, features = ["alpn"], version = "0.6" } hyper-hickory = { default-features = false, optional = true, version = "0.8.0" } secrecy = "0.10.3" -serde = { default-features = false, features = ["derive"], version = "1" } +serde = { default-features = false, features = ["derive", "std"], version = "1" } serde_json = { default-features = false, features = ["std"], version = "1" } thiserror = "2" uuid = { version = "1", features = ["v4", "serde"], optional = true } diff --git a/src/lib.rs b/src/lib.rs index dc98245..ac59db4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,21 +25,34 @@ pub struct TurnstileClient { /// Represents a request to the Turnstile API. /// +/// The `secret` parameter is not part of this struct: it is supplied by the +/// [`TurnstileClient`] the request is sent with. +/// /// #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct SiteVerifyRequest { - /// The secret key for the Turnstile API. - pub secret: Option, /// The response token from the client. pub response: String, - /// The remote IP address of the client providing the respose. - #[serde(rename = "remote_ip")] + /// The remote IP address of the client providing the response. + #[serde(rename = "remoteip", skip_serializing_if = "Option::is_none")] pub remote_ip: Option, /// The idempotency key for the request. #[cfg(feature = "idempotency")] + #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option, } +/// The body sent to the Turnstile API: the client's secret, plus the caller's request. +/// +/// Deliberately private and `Debug`-less so the secret cannot escape through a +/// formatter. Borrows the secret rather than copying it out of the [`SecretString`]. +#[derive(Serialize)] +struct SiteVerifyBody<'a> { + secret: &'a str, + #[serde(flatten)] + request: &'a SiteVerifyRequest, +} + /// Represents a succerssful response from the Turnstile API. /// /// @@ -105,17 +118,12 @@ impl TurnstileClient { &self, request: SiteVerifyRequest, ) -> Result { - // if request secret is none, set it: - let request = if request.secret.is_none() { - SiteVerifyRequest { - secret: Some(self.secret.expose_secret().to_string()), - ..request - } - } else { - request + let body = SiteVerifyBody { + secret: self.secret.expose_secret(), + request: &request, }; - let body = Full::new(Bytes::from(serde_json::to_string(&request)?)); + let body = Full::new(Bytes::from(serde_json::to_string(&body)?)); let request = Request::builder() .method(Method::POST) diff --git a/src/test.rs b/src/test.rs index 03384a0..56c9427 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,11 +1,48 @@ //! https://developers.cloudflare.com/turnstile/reference/testing/ use crate::{ error::{SiteVerifyError, TurnstileError}, - SiteVerifyRequest, TurnstileClient, + SiteVerifyBody, SiteVerifyRequest, TurnstileClient, }; type Result = std::result::Result>; +/// The wire format must match Turnstile's accepted parameters, and optional +/// parameters must be omitted rather than sent as `null`. +#[test] +fn test_request_serialization() { + let request = SiteVerifyRequest { + response: "myresponse".to_string(), + remote_ip: Some("1.2.3.4".to_string()), + ..Default::default() + }; + + let json: serde_json::Value = serde_json::to_value(SiteVerifyBody { + secret: "my-secret", + request: &request, + }) + .unwrap(); + + assert_eq!(json["secret"], "my-secret"); + assert_eq!(json["response"], "myresponse"); + // Cloudflare's parameter is `remoteip`; `remote_ip` is silently ignored. + assert_eq!(json["remoteip"], "1.2.3.4"); + assert!(json.get("remote_ip").is_none()); + + let minimal = SiteVerifyRequest { + response: "myresponse".to_string(), + ..Default::default() + }; + + assert_eq!( + serde_json::to_string(&SiteVerifyBody { + secret: "my-secret", + request: &minimal, + }) + .unwrap(), + r#"{"secret":"my-secret","response":"myresponse"}"# + ); +} + #[tokio::test] async fn test_success() -> Result<()> { let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into()); From 9f415006176dd3cb24ba1bd1ec03b345f1f5e2c7 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 10:21:18 +0200 Subject: [PATCH 03/18] Remove .cargo/config.toml and instead move lints to Cargo.toml --- .cargo/config.toml | 77 ---------------------------------------------- Cargo.toml | 7 +++++ 2 files changed, 7 insertions(+), 77 deletions(-) delete mode 100644 .cargo/config.toml diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 3ae9827..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,77 +0,0 @@ -[target.'cfg(all())'] -rustflags = [ - "-Funsafe_code", - "-Dclippy::all", - "-Wclippy::await_holding_lock", - "-Wclippy::char_lit_as_u8", - "-Wclippy::checked_conversions", - "-Wclippy::dbg_macro", - "-Wclippy::debug_assert_with_mut_call", - "-Wclippy::disallowed_methods", - "-Wclippy::disallowed_types", - "-Wclippy::empty_enum", - "-Wclippy::enum_glob_use", - "-Wclippy::exit", - "-Wclippy::expl_impl_clone_on_copy", - "-Wclippy::explicit_deref_methods", - "-Wclippy::explicit_into_iter_loop", - "-Wclippy::fallible_impl_from", - "-Wclippy::filter_map_next", - "-Wclippy::flat_map_option", - "-Wclippy::float_cmp_const", - "-Wclippy::fn_params_excessive_bools", - "-Wclippy::from_iter_instead_of_collect", - "-Wclippy::if_let_mutex", - "-Wclippy::implicit_clone", - "-Wclippy::imprecise_flops", - "-Dclippy::inefficient_to_string", - "-Wclippy::invalid_upcast_comparisons", - "-Wclippy::large_digit_groups", - "-Wclippy::large_stack_arrays", - "-Wclippy::large_types_passed_by_value", - "-Wclippy::let_unit_value", - "-Wclippy::linkedlist", - "-Wclippy::lossy_float_literal", - "-Wclippy::macro_use_imports", - "-Wclippy::manual_ok_or", - "-Wclippy::map_err_ignore", - "-Wclippy::map_flatten", - "-Wclippy::map_unwrap_or", - "-Wclippy::match_on_vec_items", - "-Wclippy::match_same_arms", - "-Wclippy::match_wild_err_arm", - "-Dclippy::match_wildcard_for_single_variants", - "-Wclippy::mem_forget", - "-Wclippy::missing_enforced_import_renames", - "-Wclippy::mut_mut", - "-Wclippy::mutex_integer", - "-Wclippy::needless_borrow", - "-Wclippy::needless_continue", - "-Wclippy::needless_for_each", - "-Wclippy::option_option", - "-Wclippy::path_buf_push_overwrite", - "-Wclippy::ptr_as_ptr", - "-Wclippy::rc_mutex", - "-Wclippy::ref_option_ref", - "-Wclippy::rest_pat_in_fully_bound_structs", - "-Wclippy::same_functions_in_if_condition", - "-Wclippy::semicolon_if_nothing_returned", - "-Wclippy::single_match_else", - "-Wclippy::string_add_assign", - "-Wclippy::string_add", - "-Wclippy::string_lit_as_bytes", - "-Wclippy::string_to_string", - "-Wclippy::todo", - "-Wclippy::trait_duplication_in_bounds", - "-Wclippy::unimplemented", - "-Wclippy::unnested_or_patterns", - "-Wclippy::unused_self", - "-Wclippy::useless_transmute", - "-Wclippy::verbose_file_reads", - "-Wclippy::zero_sized_map_values", - "-Wfuture_incompatible", - "-Wunexpected_cfgs", - "-Wmissing_docs", - "-Wnonstandard_style", - "-Wrust_2018_idioms", -] diff --git a/Cargo.toml b/Cargo.toml index bc694f8..ea1b632 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,3 +35,10 @@ integration = ["idempotency"] [dev-dependencies] tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "test-util", "macros"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "warn" + +[lints.clippy] +all = "deny" From 6a58154e12d65c546633bb7c4fa89b77292d9f3d Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 10:50:39 +0200 Subject: [PATCH 04/18] Return an error when verification was not successful Before it was necessary to check the success field, which can easily be missed by the caller. It is better to return an error, because one usually expects the verification to pass and everything else can be considered an error --- README.md | 3 ++- src/error.rs | 8 ++++++++ src/lib.rs | 34 ++++++++++++++++++++++++---------- src/test.rs | 5 +++-- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6a10241..d9ead08 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ let validated = client.siteverify(SiteVerifyRequest { ..Default::default() }).await?; -assert!(validated.success); +// `siteverify` returns `Err` unless Cloudflare verified the token. +println!("verified on {}", validated.hostname); ``` ## Features diff --git a/src/error.rs b/src/error.rs index 3ab6548..084d9f9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,14 @@ pub enum TurnstileError { #[error("Turnstile API error: {0:?}")] SiteVerifyError(SiteVerifyErrors), + /// The Turnstile API responded with a non-success HTTP status. + #[error("Turnstile API returned HTTP status {0}")] + UnexpectedStatus(hyper::StatusCode), + + /// The Turnstile API rejected the token but returned no error code. + #[error("Turnstile rejected the token without returning an error code")] + VerificationFailed, + /// The error originated from Legacy Hyper. #[error("Legacy hyper error: {0:?}")] LegacyHyperError(#[from] hyper_util::client::legacy::Error), diff --git a/src/lib.rs b/src/lib.rs index ac59db4..6cfca36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,15 +53,17 @@ struct SiteVerifyBody<'a> { request: &'a SiteVerifyRequest, } -/// Represents a succerssful response from the Turnstile API. +/// Represents a successful response from the Turnstile API. /// -/// -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Deliberately neither [`Serialize`] nor [`Deserialize`]: this type is only ever +/// produced by [`TurnstileClient::siteverify`], which returns it solely when +/// Cloudflare verified the token. Parsing one from arbitrary JSON would yield a +/// value that looks verified without any verification having taken place. +/// +/// +#[derive(Debug, Clone)] pub struct SiteVerifyResponse { - /// Whether the request was successful. - pub success: bool, - /// The timestamp of the request. - #[serde(rename = "challenge_ts")] + /// The timestamp of the request, from the API's `challenge_ts` field. pub timestamp: String, /// The hostname of the request. pub hostname: String, @@ -74,7 +76,6 @@ pub struct SiteVerifyResponse { impl From for SiteVerifyResponse { fn from(raw: RawSiteVerifyResponse) -> Self { Self { - success: raw.success, timestamp: raw.timestamp.unwrap_or_default(), hostname: raw.hostname.unwrap_or_default(), action: raw.action.unwrap_or_default(), @@ -83,7 +84,7 @@ impl From for SiteVerifyResponse { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] struct RawSiteVerifyResponse { success: bool, #[serde(rename = "challenge_ts")] @@ -135,13 +136,26 @@ impl TurnstileClient { let response = self.http.request(request).await?; - let body_bytes = response.collect().await?.to_bytes(); + // Read the status before the body: `Response` itself implements `Body`, so + // collecting the response rather than its body silently discards the status. + let status = response.status(); + if !status.is_success() { + return Err(TurnstileError::UnexpectedStatus(status)); + } + + let body_bytes = response.into_body().collect().await?.to_bytes(); let body = serde_json::from_slice::(&body_bytes)?; if !body.error_codes.is_empty() { return Err(TurnstileError::SiteVerifyError(body.error_codes)); } + // Cloudflare always accompanies `success: false` with an error code, but do + // not rely on it: a caller using `?` must never be handed an unverified token. + if !body.success { + return Err(TurnstileError::VerificationFailed); + } + let transformed = SiteVerifyResponse::from(body); Ok(transformed) diff --git a/src/test.rs b/src/test.rs index 56c9427..221bcfa 100644 --- a/src/test.rs +++ b/src/test.rs @@ -54,7 +54,9 @@ async fn test_success() -> Result<()> { }) .await?; - assert!(validated.success); + // `siteverify` returns `Err` unless the token was verified, so reaching this + // point is the assertion; check the payload was parsed as well. + assert!(!validated.timestamp.is_empty()); Ok(()) } @@ -120,7 +122,6 @@ async fn test_integration() -> Result<()> { }) .await?; - assert!(validated.success); assert_eq!(validated.hostname, hostname); println!("validated: {:#?}", validated); From 2696d8a9e85b5cba324fc6c6acb81fa71c5dded9 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 10:56:23 +0200 Subject: [PATCH 05/18] Limit the size of the site verification response Right now, a malicious response might cause us to buffer a huge response message (depending on any internal limitations of reqwest or so). Since we expect only a couple of hundred bytes, we limit it. --- src/error.rs | 7 +++++++ src/lib.rs | 23 +++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index 084d9f9..294b6a0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,13 @@ pub enum TurnstileError { #[error("Turnstile rejected the token without returning an error code")] VerificationFailed, + /// The response body exceeded the maximum size the client will buffer. + #[error( + "Turnstile API response body exceeded {} bytes", + crate::MAX_RESPONSE_BYTES + )] + ResponseTooLarge, + /// The error originated from Legacy Hyper. #[error("Legacy hyper error: {0:?}")] LegacyHyperError(#[from] hyper_util::client::legacy::Error), diff --git a/src/lib.rs b/src/lib.rs index 6cfca36..8adb96b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ #![doc = include_str!("../README.md")] use connector::Connector; use error::{SiteVerifyErrors, TurnstileError}; -use http_body_util::{BodyExt, Full}; +use http_body_util::{BodyExt, Full, Limited}; use hyper::{ body::Bytes, header::{CONTENT_TYPE, USER_AGENT}, @@ -96,6 +96,11 @@ struct RawSiteVerifyResponse { cdata: Option, } +/// Maximum accepted size of a siteverify response body. The real endpoint returns +/// a few hundred bytes; this only exists to bound what a hostile or broken upstream +/// can make the client buffer. +const MAX_RESPONSE_BYTES: usize = 64 * 1024; + const TURNSTILE_USER_AGENT: &str = concat!( "cf-turnstile (", env!("CARGO_PKG_HOMEPAGE"), @@ -143,7 +148,21 @@ impl TurnstileClient { return Err(TurnstileError::UnexpectedStatus(status)); } - let body_bytes = response.into_body().collect().await?.to_bytes(); + let body_bytes = match Limited::new(response.into_body(), MAX_RESPONSE_BYTES) + .collect() + .await + { + Ok(collected) => collected.to_bytes(), + // `Limited` boxes the inner body's error, so anything that is not a + // transport failure is the length limit being hit. + Err(err) => { + return Err(match err.downcast::() { + Ok(err) => TurnstileError::HyperError(*err), + Err(_) => TurnstileError::ResponseTooLarge, + }) + } + }; + let body = serde_json::from_slice::(&body_bytes)?; if !body.error_codes.is_empty() { From b85c9a74cd290d8063ad07bf2c73f27bb1b5765a Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 11:03:53 +0200 Subject: [PATCH 06/18] Make response error code parsing more resilient Right now, if cloudflare adds a new error code, this prematurely errors in the parser, losing some more info from the response. This introduces a catchall and removes some old error codes that are no longer used by cloudflare. Also update broken link --- src/error.rs | 33 +++++++++------------------------ src/lib.rs | 3 ++- src/test.rs | 25 ++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/error.rs b/src/error.rs index 294b6a0..f2f62c9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,17 +1,6 @@ //! Error types for the Turnstile API. use serde::{Deserialize, Serialize}; use thiserror::Error; -// ​​Error codes -// Error code Description -// missing-input-secret The secret parameter was not passed. -// invalid-input-secret The secret parameter was invalid or did not exist. -// missing-input-response The response parameter was not passed. -// invalid-input-response The response parameter is invalid or has expired. -// invalid-widget-id The widget ID extracted from the parsed site secret key was invalid or did not exist. -// invalid-parsed-secret The secret extracted from the parsed site secret key was invalid. -// bad-request The request was rejected because it was malformed. -// timeout-or-duplicate The response parameter has already been validated before. -// internal-error An internal error happened while validating the response. The request can be retried. /// Represents a list of errors from the Turnstile API. #[derive(Debug, Error)] @@ -53,7 +42,7 @@ pub type SiteVerifyErrors = Vec; /// Represents an error from the Turnstile API. /// -/// +/// #[derive(Debug, Clone, Error, Deserialize, Serialize)] pub enum SiteVerifyError { /// The secret parameter was not passed. @@ -76,18 +65,6 @@ pub enum SiteVerifyError { #[error("The response parameter is invalid or has expired.")] InvalidInputResponse, - /// The widget ID extracted from the parsed site secret key was invalid or did not exist. - #[serde(rename = "invalid-widget-id")] - #[error( - "The widget ID extracted from the parsed site secret key was invalid or did not exist." - )] - InvalidWidgetId, - - /// The secret extracted from the parsed site secret key was invalid. - #[serde(rename = "invalid-parsed-secret")] - #[error("The secret extracted from the parsed site secret key was invalid.")] - InvalidParsedSecret, - /// The request was rejected because it was malformed. #[serde(rename = "bad-request")] #[error("The request was rejected because it was malformed.")] @@ -104,4 +81,12 @@ pub enum SiteVerifyError { "An internal error happened while validating the response. The request can be retried." )] InternalError, + + /// An error code not known to this version of the crate. + /// + /// Without this catch-all a single unrecognised code would fail the whole + /// response, costing the caller every other code in the array. + #[serde(other)] + #[error("The Turnstile API returned an error code unknown to this version of the crate.")] + Unknown, } diff --git a/src/lib.rs b/src/lib.rs index 8adb96b..6bda81a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -90,7 +90,8 @@ struct RawSiteVerifyResponse { #[serde(rename = "challenge_ts")] timestamp: Option, hostname: Option, - #[serde(rename = "error-codes")] + /// Absent rather than empty on some responses, so treat a missing key as "no errors". + #[serde(rename = "error-codes", default)] error_codes: SiteVerifyErrors, action: Option, cdata: Option, diff --git a/src/test.rs b/src/test.rs index 221bcfa..72155eb 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,7 +1,7 @@ //! https://developers.cloudflare.com/turnstile/reference/testing/ use crate::{ error::{SiteVerifyError, TurnstileError}, - SiteVerifyBody, SiteVerifyRequest, TurnstileClient, + RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest, TurnstileClient, }; type Result = std::result::Result>; @@ -43,6 +43,29 @@ fn test_request_serialization() { ); } +/// A code Cloudflare adds later must not cost us the rest of the response, and a +/// missing `error-codes` key must mean "no errors" rather than a parse failure. +#[test] +fn test_unknown_error_code() { + let raw: RawSiteVerifyResponse = serde_json::from_str( + r#"{"success":false,"error-codes":["invalid-input-response","brand-new-code"]}"#, + ) + .unwrap(); + + assert!(matches!( + raw.error_codes.as_slice(), + [ + SiteVerifyError::InvalidInputResponse, + SiteVerifyError::Unknown + ] + )); + + let raw: RawSiteVerifyResponse = + serde_json::from_str(r#"{"success":true,"hostname":"example.com"}"#).unwrap(); + + assert!(raw.error_codes.is_empty()); +} + #[tokio::test] async fn test_success() -> Result<()> { let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into()); From 70859a4019cc625b476cc083bf361cb088c507c6 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 12:55:41 +0200 Subject: [PATCH 07/18] fix test_fail test_fail only asserts is_err(). A DNS failure, a TLS handshake failure, a timeout, or a SerdeError from an unparseable body all satisfy it equally; so if valid rejections regressed into parse errors, the test would still pass --- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 3 +++ Makefile.toml | 4 ++-- src/test.rs | 24 +++++++++++++++++++----- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97f6560..d76160d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Register Problem Matchers uses: r7kamura/rust-problem-matchers@v1 @@ -49,4 +49,4 @@ jobs: run: cargo build --release --all-features - name: Run Unit Tests - run: cargo make test + run: cargo make test-ci diff --git a/Cargo.toml b/Cargo.toml index ea1b632..5a2eb35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,9 @@ rustls-native-roots = ["dep:hyper-rustls", "hyper-rustls?/native-tokio"] rustls-webpki-roots = ["dep:hyper-rustls", "hyper-rustls?/webpki-tokio"] hickory = ["dep:hyper-hickory", "hyper-hickory?/tokio"] integration = ["idempotency"] +# Tests that call the live Turnstile API with Cloudflare's dummy keys. Off by +# default so `cargo test` works offline; `cargo make test` enables it. +network-tests = [] [dev-dependencies] tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "test-util", "macros"] } diff --git a/Makefile.toml b/Makefile.toml index 31265d4..3b940f9 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -50,12 +50,12 @@ args = ["fmt", "--all", "--", "--check"] [tasks.test] env = { "RUN_MODE" = "test", "RUST_LOG" = "info" } command = "cargo" -args = ["nextest", "run", "${@}"] +args = ["nextest", "run", "--features", "network-tests", "${@}"] [tasks.test-ci] env = { "RUN_MODE" = "ci", "RUST_LOG" = "info" } command = "cargo" -args = ["nextest", "run"] +args = ["nextest", "run", "--features", "network-tests"] [tasks.cov] command = "cargo" diff --git a/src/test.rs b/src/test.rs index 72155eb..3b09a2c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,9 +1,12 @@ //! https://developers.cloudflare.com/turnstile/reference/testing/ -use crate::{ - error::{SiteVerifyError, TurnstileError}, - RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest, TurnstileClient, -}; +use crate::{error::SiteVerifyError, RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest}; +#[cfg(feature = "network-tests")] +use crate::error::TurnstileError; +#[cfg(any(feature = "network-tests", feature = "integration"))] +use crate::TurnstileClient; + +#[cfg(any(feature = "network-tests", feature = "integration"))] type Result = std::result::Result>; /// The wire format must match Turnstile's accepted parameters, and optional @@ -66,6 +69,7 @@ fn test_unknown_error_code() { assert!(raw.error_codes.is_empty()); } +#[cfg(feature = "network-tests")] #[tokio::test] async fn test_success() -> Result<()> { let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into()); @@ -84,6 +88,7 @@ async fn test_success() -> Result<()> { Ok(()) } +#[cfg(feature = "network-tests")] #[tokio::test] async fn test_fail() -> Result<()> { let client = TurnstileClient::new("2x0000000000000000000000000000000AA".to_string().into()); @@ -95,11 +100,20 @@ async fn test_fail() -> Result<()> { }) .await; - assert!(validated.is_err()); + // Assert the API rejected the token, not merely that something went wrong: + // a DNS, TLS or parse failure must not satisfy this test. + match validated.unwrap_err() { + TurnstileError::SiteVerifyError(codes) => assert!( + matches!(codes.as_slice(), [SiteVerifyError::InvalidInputResponse]), + "unexpected error codes: {codes:?}" + ), + e => panic!("expected a Turnstile API rejection, got: {e}"), + } Ok(()) } +#[cfg(feature = "network-tests")] #[tokio::test] async fn test_token_already_spent() -> Result<()> { let client = TurnstileClient::new("3x0000000000000000000000000000000AA".to_string().into()); From a9446f4913bd0ed00fe4bb7de584f6403256ad11 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 14:45:08 +0200 Subject: [PATCH 08/18] Revert compiler errors with multiple tls backend features Cargo features are additive, so we must allow multiple tls backends so that when cf-turnstile is pulled in to the dependency graph twice with different TLS backends, it must still compile. --- README.md | 15 ++++++++++++--- src/connector.rs | 8 ++++++++ src/lib.rs | 26 ++++++-------------------- src/test.rs | 3 ++- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d9ead08..7e976e1 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,22 @@ This will enable the `idempotency_key` field on the [`SiteVerifyRequest`](struct ### TLS -**Note**: Turnstile's API is HTTPS only, so exactly one TLS feature must be enabled. +**Note**: Turnstile's API is HTTPS only, so at least one TLS feature must be enabled. Building without a TLS backend is a compile error. **Note**: this TLS code was taken from [twilight-http](https://github.com/twilight-rs/twilight/tree/main/twilight-http) in accordance with its license. -`cf-turnstile` has features to enable HTTPS connectivity with [`hyper`]. These -features are mutually exclusive. `rustls-native-roots` is enabled by default. +`cf-turnstile` has features to enable HTTPS connectivity with [`hyper`]. +`rustls-native-roots` is enabled by default. + +Enabling more than one backend is allowed rather than a build failure: Cargo features +are additive, so two unrelated crates in the same dependency graph may each select a +different backend, and that combination has to keep compiling. When several are +enabled the backend is chosen by precedence: + +1. `rustls-native-roots` +2. `rustls-webpki-roots` +3. `native-tls` #### `native` diff --git a/src/connector.rs b/src/connector.rs index 66f669d..8b97f0c 100644 --- a/src/connector.rs +++ b/src/connector.rs @@ -1,5 +1,13 @@ //! HTTP connectors with different features. //! +//! More than one TLS backend may be enabled at once, because Cargo features are +//! additive and unrelated crates in the same dependency graph may each select a +//! different one. Rather than failing to build, the backend is chosen by precedence: +//! +//! 1. `rustls-native-roots` +//! 2. `rustls-webpki-roots` +//! 3. `native-tls` +//! //! Taken from [twilight-http](https://github.com/twilight-rs/twilight/blob/main/twilight-http/src/client/connector.rs) //! //! ISC License (ISC) - Copyright (c) 2019 (c) The Twilight Contributors diff --git a/src/lib.rs b/src/lib.rs index 6bda81a..ca095f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -190,31 +190,17 @@ pub fn generate_idempotency_key() -> Option { // Turnstile's API is HTTPS only. Without a TLS backend the client would send the // secret key over an unencrypted connection, so refuse to build instead. +// +// Enabling *several* backends is not an error: Cargo features are additive, and two +// unrelated crates in one dependency graph may each ask for a different backend. That +// is unresolvable if the combination fails to compile, so `connector::create` picks by +// precedence instead — see its module docs. #[cfg(not(any( feature = "native-tls", feature = "rustls-native-roots", feature = "rustls-webpki-roots" )))] compile_error!( - r#"A TLS backend is required: enable exactly one of "rustls-native-roots" (the default), "rustls-webpki-roots" or "native-tls". + r#"A TLS backend is required: enable at least one of "rustls-native-roots" (the default), "rustls-webpki-roots" or "native-tls". Turnstile's API is HTTPS only, and without TLS the secret key would be sent in cleartext."# ); - -// Some features are mutually exclusive. This is documented in the readme, but also gives a compile-time error -#[cfg(all(feature = "native-tls", feature = "rustls-native-roots"))] -compile_error!( - r#"The features "native-tls" and "rustls-native-roots" are mutually exclusive. Please enable only one TLS backend. -If you're enabling "native-tls", make sure to set `default-features = false` to disable the default "rustls-native-roots" feature."# -); - -#[cfg(all(feature = "native-tls", feature = "rustls-webpki-roots"))] -compile_error!( - r#"The features "native-tls" and "rustls-webpki-roots" are mutually exclusive. Please enable only one TLS backend. -If you're enabling "native-tls", make sure to set `default-features = false` to disable the default "rustls-native-roots" feature."# -); - -#[cfg(all(feature = "rustls-native-roots", feature = "rustls-webpki-roots"))] -compile_error!( - r#"The features "rustls-native-roots" and "rustls-webpki-roots" are mutually exclusive. Please enable only one TLS backend. -If you're enabling "native-tls", make sure to set `default-features = false` to disable the default "rustls-native-roots" feature."# -); diff --git a/src/test.rs b/src/test.rs index 3b09a2c..59a0b33 100644 --- a/src/test.rs +++ b/src/test.rs @@ -16,7 +16,8 @@ fn test_request_serialization() { let request = SiteVerifyRequest { response: "myresponse".to_string(), remote_ip: Some("1.2.3.4".to_string()), - ..Default::default() + #[cfg(feature = "idempotency")] + idempotency_key: None, }; let json: serde_json::Value = serde_json::to_value(SiteVerifyBody { From 67fc3bd90efe8b514ddb8de19d9d5dc407cfc221 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 14:51:18 +0200 Subject: [PATCH 09/18] fix typos --- Cargo.toml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5a2eb35..5d2f997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ description = "A Rust client for Cloudflare Turnstile" homepage = "https://github.com/sycertech/cf-turnstile" repository = "https://github.com/sycertech/cf-turnstile" categories = ["api-bindings", "asynchronous", "web-programming::http-client"] -keywords = ["cloduflare", "turnstile", "recaptcha", "captcha"] +keywords = ["cloudflare", "turnstile", "recaptcha", "captcha"] license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 7e976e1..4bc7920 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!NOTE] -> This is an actively maintained frok of https://github.com/Fyko/cf-turnstile +> This is an actively maintained fork of https://github.com/Fyko/cf-turnstile # cf-turnstile From f41b4a3f163cea1016f33ea0d200e06907c81b2e Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 14:56:13 +0200 Subject: [PATCH 10/18] Update README.md --- README.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4bc7920..632d69a 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,25 @@ A Rust client for [Cloudflare Turnstile]. # Example -```rust,ignore +```rust,no_run use cf_turnstile::{SiteVerifyRequest, TurnstileClient}; -let client = TurnstileClient::new("my-secret".to_string().into()); +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = TurnstileClient::new("my-secret".to_string().into()); -let validated = client.siteverify(SiteVerifyRequest { - response: "myresponse".to_string(), - ..Default::default() -}).await?; + let validated = client + .siteverify(SiteVerifyRequest { + response: "myresponse".to_string(), + ..Default::default() + }) + .await?; -// `siteverify` returns `Err` unless Cloudflare verified the token. -println!("verified on {}", validated.hostname); + // `siteverify` returns `Err` unless Cloudflare verified the token. + println!("verified on {}", validated.hostname); + + Ok(()) +} ``` ## Features @@ -48,15 +55,15 @@ enabled the backend is chosen by precedence: 2. `rustls-webpki-roots` 3. `native-tls` -#### `native` +#### `native-tls` -The `native` feature uses a HTTPS connector provided by [`hyper-tls`]. +The `native-tls` feature uses a HTTPS connector provided by [`hyper-tls`]. -To enable `native`, do something like this in your `Cargo.toml`: +To enable `native-tls`, do something like this in your `Cargo.toml`: ```toml [dependencies] -cf-turnstile = { default-features = false, features = ["native"], version = "0.1" } +cf-turnstile = { default-features = false, features = ["native-tls", "hickory"], version = "0.2" } ``` #### `rustls-native-roots` @@ -75,17 +82,20 @@ for root certificates. This should be preferred over `rustls-native-roots` in Docker containers based on `scratch`. -### Trust-DNS +### Hickory DNS -The `trust-dns` enables [`hyper-trust-dns`], which replaces the default -`GaiResolver` in [`hyper`]. [`hyper-trust-dns`] instead provides a fully +The `hickory` feature enables [`hyper-hickory`], which replaces the default +`GaiResolver` in [`hyper`]. [`hyper-hickory`] instead provides a fully async DNS resolver on the application level. +This is enabled by default. Note that `default-features = false` turns it off, so +add it back explicitly if you want it alongside a non-default TLS backend. + [Cloudflare Turnstile]: https://developers.cloudflare.com/turnstile/ [`hyper`]: https://crates.io/crates/hyper [`hyper-rustls`]: https://crates.io/crates/hyper-rustls [`hyper-tls`]: https://crates.io/crates/hyper-tls [`rustls`]: https://crates.io/crates/rustls [`rustls-native-certs`]: https://crates.io/crates/rustls-native-certs -[`hyper-trust-dns`]: https://crates.io/crates/hyper-trust-dns +[`hyper-hickory`]: https://crates.io/crates/hyper-hickory [`webpki-roots`]: https://crates.io/crates/webpki-roots From 320bd00f0f340aa924de84dc8b9861b3018f5bb7 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 14:56:50 +0200 Subject: [PATCH 11/18] Bump version to 0.3 --- Cargo.toml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5d2f997..26f8af9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cf-turnstile" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "A Rust client for Cloudflare Turnstile" homepage = "https://github.com/sycertech/cf-turnstile" diff --git a/README.md b/README.md index 632d69a..26a559e 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ To enable `native-tls`, do something like this in your `Cargo.toml`: ```toml [dependencies] -cf-turnstile = { default-features = false, features = ["native-tls", "hickory"], version = "0.2" } +cf-turnstile = { default-features = false, features = ["native-tls", "hickory"], version = "0.3" } ``` #### `rustls-native-roots` From fbbdc1fdfddd42e3ca7274fdca27e9cda848e031 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 15:01:36 +0200 Subject: [PATCH 12/18] Write serialized body to a Zeroizing buffer so it is deleted properly --- Cargo.toml | 4 +++- src/lib.rs | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 26f8af9..37109de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +bytes = { default-features = false, version = "1.12.1" } http-body-util = { default-features = false, version = "0.1" } -hyper = { default-features = false, version = "1" } +hyper = { version = "1.11.0", default-features = false } hyper-util = { default-features = false, features = ["client-legacy", "http1", "http2", "tokio"], version = "0.1.20" } hyper-rustls = { default-features = false, optional = true, features = ["http1", "http2", "ring"], version = "0.27.9" } hyper-tls = { default-features = false, optional = true, features = ["alpn"], version = "0.6" } @@ -23,6 +24,7 @@ serde = { default-features = false, features = ["derive", "std"], version = "1" serde_json = { default-features = false, features = ["std"], version = "1" } thiserror = "2" uuid = { version = "1", features = ["v4", "serde"], optional = true } +zeroize = "1.9.0" [features] default = ["rustls-native-roots", "hickory"] diff --git a/src/lib.rs b/src/lib.rs index ca095f7..ab340fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ use hyper::{ use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; mod connector; pub mod error; @@ -130,7 +131,11 @@ impl TurnstileClient { request: &request, }; - let body = Full::new(Bytes::from(serde_json::to_string(&body)?)); + // The serialized body contains the secret key. Hand `Bytes` a zeroizing owner + // so the buffer is wiped when the request is done rather than merely freed, + // which would leave the key readable in a core dump or swapped-out page. + let body = Zeroizing::new(serde_json::to_vec(&body)?); + let body = Full::new(Bytes::from_owner(body)); let request = Request::builder() .method(Method::POST) From 786af48db88c8d01bba0d2122cbb34ff687ebe3c Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 15:03:26 +0200 Subject: [PATCH 13/18] Make versions in Cargo.toml specific up to patch Run taplo fmt on the toml files --- Cargo.toml | 40 +++++++++++++++++++++++++++++----------- Makefile.toml | 48 +++++++++++++----------------------------------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 37109de..260ced8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,18 +12,32 @@ license = "MIT" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -bytes = { default-features = false, version = "1.12.1" } -http-body-util = { default-features = false, version = "0.1" } +bytes = { version = "1.12.1", default-features = false } +http-body-util = { version = "0.1.4", default-features = false } hyper = { version = "1.11.0", default-features = false } -hyper-util = { default-features = false, features = ["client-legacy", "http1", "http2", "tokio"], version = "0.1.20" } -hyper-rustls = { default-features = false, optional = true, features = ["http1", "http2", "ring"], version = "0.27.9" } -hyper-tls = { default-features = false, optional = true, features = ["alpn"], version = "0.6" } -hyper-hickory = { default-features = false, optional = true, version = "0.8.0" } +hyper-util = { version = "0.1.20", default-features = false, features = [ + "client-legacy", + "http1", + "http2", + "tokio", +] } +hyper-rustls = { version = "0.27.9", optional = true, default-features = false, features = [ + "http1", + "http2", + "ring", +] } +hyper-tls = { version = "0.6.0", optional = true, default-features = false, features = [ + "alpn", +] } +hyper-hickory = { version = "0.8.0", optional = true, default-features = false } secrecy = "0.10.3" -serde = { default-features = false, features = ["derive", "std"], version = "1" } -serde_json = { default-features = false, features = ["std"], version = "1" } -thiserror = "2" -uuid = { version = "1", features = ["v4", "serde"], optional = true } +serde = { version = "1.0.229", default-features = false, features = [ + "derive", + "std", +] } +serde_json = { version = "1.0.151", features = ["std"] } +thiserror = "2.0.19" +uuid = { version = "1.24.0", optional = true, features = ["v4", "serde"] } zeroize = "1.9.0" [features] @@ -39,7 +53,11 @@ integration = ["idempotency"] network-tests = [] [dev-dependencies] -tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "test-util", "macros"] } +tokio = { version = "1", default-features = false, features = [ + "rt-multi-thread", + "test-util", + "macros", +] } [lints.rust] unsafe_code = "forbid" diff --git a/Makefile.toml b/Makefile.toml index 3b940f9..ecb347b 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -11,26 +11,20 @@ script = ''' [tasks.lint] install_crate = "clippy" command = "cargo" -args = [ - "clippy", - "--tests", - "--examples", - "--all-targets", - "--all-features", -] +args = ["clippy", "--tests", "--examples", "--all-targets", "--all-features"] [tasks.lint-ci] install_crate = "clippy" command = "cargo" args = [ - "clippy", - "--tests", - "--examples", - "--all-targets", - "--all-features", - "--", - "-D", - "warnings", + "clippy", + "--tests", + "--examples", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", ] [tasks.format] @@ -60,36 +54,20 @@ args = ["nextest", "run", "--features", "network-tests"] [tasks.cov] command = "cargo" env = { "RUN_MODE" = "test" } -args = [ - "llvm-cov", "nextest", - "${@}" -] +args = ["llvm-cov", "nextest", "${@}"] [tasks.cov-ci] command = "cargo" env = { "RUN_MODE" = "ci" } -args = [ - "llvm-cov", "nextest", - "--lcov", "--output-path", "lcov.info" -] +args = ["llvm-cov", "nextest", "--lcov", "--output-path", "lcov.info"] [tasks.docs] command = "cargo" -args = [ - "doc", - "--no-deps", - "--all-features", - "--document-private-items", -] +args = ["doc", "--no-deps", "--all-features", "--document-private-items"] [tasks.docs-watch] command = "cargo" -args = [ - "doc", - "--no-deps", - "--all-features", - "--document-private-items", -] +args = ["doc", "--no-deps", "--all-features", "--document-private-items"] watch = true [tasks.timings] From d685ae97c5a5443f7e7d0836edb8cb15f6fa5435 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 15:25:40 +0200 Subject: [PATCH 14/18] Add usage example with timeout And update the docs accordingly, mentioning the idempotency feature --- Cargo.toml | 2 ++ src/lib.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 260ced8..37ba32b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ tokio = { version = "1", default-features = false, features = [ "rt-multi-thread", "test-util", "macros", + # Used only by the timeout example in `siteverify`'s docs. + "time", ] } [lints.rust] diff --git a/src/lib.rs b/src/lib.rs index ab340fd..444bb62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,6 +122,41 @@ impl TurnstileClient { } /// Verify a Cloudflare Turnstile response. + /// + /// # Timeouts + /// + /// No timeout is applied, and hyper's client has none of its own, so a stalled + /// connection waits indefinitely. In a request handler that pins a task and a + /// connection until the process runs out of both. The latency budget belongs to + /// the caller, so bound it at the call site: + /// + /// ```no_run + /// # use cf_turnstile::{SiteVerifyRequest, SiteVerifyResponse, TurnstileClient}; + /// # use cf_turnstile::error::TurnstileError; + /// # async fn verify( + /// # client: &TurnstileClient, + /// # request: SiteVerifyRequest, + /// # ) -> Option> { + /// use std::time::Duration; + /// + /// tokio::time::timeout(Duration::from_secs(5), client.siteverify(request)) + /// .await + /// .ok() + /// # } + /// ``` + /// + /// This bounds the whole operation: connect, TLS handshake, response and body read. + /// + /// # Cancellation + /// + /// Dropping the returned future is safe, but it does not un-send the request. If + /// that already reached Cloudflare the token is spent, since each token may only + /// be validated once, so retrying with the same token returns + /// [`SiteVerifyError::TimeoutOrDuplicate`]. To retry safely, enable the + /// `idempotency` feature and send the same `idempotency_key` on every attempt, + /// generated once before the first call. + /// + /// [`SiteVerifyError::TimeoutOrDuplicate`]: error::SiteVerifyError::TimeoutOrDuplicate pub async fn siteverify( &self, request: SiteVerifyRequest, From 8af7f2385f95bb865c13cc8c0945cfbb4e74b700 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Tue, 4 Aug 2026 15:32:21 +0200 Subject: [PATCH 15/18] Improve docs --- src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 444bb62..080c72a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -223,6 +223,10 @@ impl TurnstileClient { } /// Generate a new idempotency key. +/// +/// Call this once per token, before the first attempt, and reuse the value if you +/// retry. Generating a fresh key per attempt makes each one a separate validation, +/// which fails once the token is spent. #[cfg(feature = "idempotency")] pub fn generate_idempotency_key() -> Option { Some(uuid::Uuid::new_v4()) From b2d996dcbb19fa83e4aae28a2a022a1ab0d67268 Mon Sep 17 00:00:00 2001 From: Julian Dickert Date: Fri, 7 Aug 2026 09:28:12 +0200 Subject: [PATCH 16/18] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joël Schulz-Andres Co-authored-by: Julian Dickert --- Cargo.toml | 2 ++ src/lib.rs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 37ba32b..c4f86c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,3 +67,5 @@ missing_docs = "warn" [lints.clippy] all = "deny" +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } diff --git a/src/lib.rs b/src/lib.rs index 080c72a..eecc545 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -118,7 +118,7 @@ impl TurnstileClient { let http = hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector); - Self { http, secret } + Self { secret, http } } /// Verify a Cloudflare Turnstile response. @@ -168,7 +168,7 @@ impl TurnstileClient { // The serialized body contains the secret key. Hand `Bytes` a zeroizing owner // so the buffer is wiped when the request is done rather than merely freed, - // which would leave the key readable in a core dump or swapped-out page. + // which is best practice, but only works on a best-effort level, not a 100% guarantee. let body = Zeroizing::new(serde_json::to_vec(&body)?); let body = Full::new(Bytes::from_owner(body)); From 826e5fdb980119c18f5061faa4311b75dc95cae0 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Fri, 7 Aug 2026 09:33:35 +0200 Subject: [PATCH 17/18] fix: cargo clippy errors on pedantic level --- README.md | 2 +- src/lib.rs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 26a559e..b3036b9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!NOTE] -> This is an actively maintained fork of https://github.com/Fyko/cf-turnstile +> This is an actively maintained fork of # cf-turnstile diff --git a/src/lib.rs b/src/lib.rs index eecc545..b78f131 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,6 +113,7 @@ const TURNSTILE_USER_AGENT: &str = concat!( impl TurnstileClient { /// Create a new Turnstile client. + #[must_use] pub fn new(secret: SecretString) -> Self { let connector = connector::create(); let http = @@ -157,6 +158,12 @@ impl TurnstileClient { /// generated once before the first call. /// /// [`SiteVerifyError::TimeoutOrDuplicate`]: error::SiteVerifyError::TimeoutOrDuplicate + /// + /// # Errors + /// Returns a [`TurnstileError`] containing details about which step of the verification failed. + /// + /// # Panics + /// When the http Request Builder returns an error, which should never happen and is covered by tests. pub async fn siteverify( &self, request: SiteVerifyRequest, @@ -197,10 +204,10 @@ impl TurnstileClient { // `Limited` boxes the inner body's error, so anything that is not a // transport failure is the length limit being hit. Err(err) => { - return Err(match err.downcast::() { - Ok(err) => TurnstileError::HyperError(*err), - Err(_) => TurnstileError::ResponseTooLarge, - }) + return Err(err.downcast::().map_or_else( + |_| TurnstileError::ResponseTooLarge, + |err| TurnstileError::HyperError(*err), + )) } }; From 818a33d6c82d14b75a1c66f520bd7fd9eb327db1 Mon Sep 17 00:00:00 2001 From: JuliDi Date: Fri, 7 Aug 2026 09:38:08 +0200 Subject: [PATCH 18/18] fix: apply more clippy pedantic suggestions --- src/lib.rs | 1 + src/test.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b78f131..18f6417 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -235,6 +235,7 @@ impl TurnstileClient { /// retry. Generating a fresh key per attempt makes each one a separate validation, /// which fails once the token is spent. #[cfg(feature = "idempotency")] +#[must_use] pub fn generate_idempotency_key() -> Option { Some(uuid::Uuid::new_v4()) } diff --git a/src/test.rs b/src/test.rs index 59a0b33..34f28b4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,4 +1,4 @@ -//! https://developers.cloudflare.com/turnstile/reference/testing/ +//! use crate::{error::SiteVerifyError, RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest}; #[cfg(feature = "network-tests")] @@ -132,7 +132,7 @@ async fn test_token_already_spent() -> Result<()> { SiteVerifyError::TimeoutOrDuplicate => {} _ => panic!("Unexpected error"), }, - e => panic!("Unexpected error: {}", e), + e => panic!("Unexpected error: {e}"), } Ok(()) @@ -162,7 +162,7 @@ async fn test_integration() -> Result<()> { assert_eq!(validated.hostname, hostname); - println!("validated: {:#?}", validated); + println!("validated: {validated:#?}"); Ok(()) }