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/.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 6379ca1..c4f86c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,44 @@ [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" 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 [dependencies] -http-body-util = { default-features = false, version = "0.1" } -hyper = { default-features = false, version = "1" } -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" } +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 = { 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"], 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] default = ["rustls-native-roots", "hickory"] @@ -32,6 +48,24 @@ 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"] } +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] +unsafe_code = "forbid" +missing_docs = "warn" + +[lints.clippy] +all = "deny" +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } diff --git a/Makefile.toml b/Makefile.toml index 31265d4..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] @@ -50,46 +44,30 @@ 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" 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] diff --git a/README.md b/README.md index 3a13198..b3036b9 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 # cf-turnstile @@ -7,17 +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?; -assert!(validated.success); + // `siteverify` returns `Err` unless Cloudflare verified the token. + println!("verified on {}", validated.hostname); + + Ok(()) +} ``` ## Features @@ -30,23 +38,32 @@ 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 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: -#### `native` +1. `rustls-native-roots` +2. `rustls-webpki-roots` +3. `native-tls` -The `native` feature uses a HTTPS connector provided by [`hyper-tls`]. +#### `native-tls` -To enable `native`, do something like this in your `Cargo.toml`: +The `native-tls` feature uses a HTTPS connector provided by [`hyper-tls`]. + +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.3" } ``` #### `rustls-native-roots` @@ -65,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 diff --git a/src/connector.rs b/src/connector.rs index 80d1575..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 @@ -43,20 +51,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 +75,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/error.rs b/src/error.rs index 3ab6548..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)] @@ -20,6 +9,21 @@ 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 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), @@ -38,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. @@ -61,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.")] @@ -89,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 12fe8bb..18f6417 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}, @@ -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; @@ -25,30 +26,45 @@ 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, } -/// Represents a succerssful response from the Turnstile API. +/// 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 successful response from the Turnstile API. +/// +/// 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, Serialize, Deserialize)] +/// +#[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, @@ -61,7 +77,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(), @@ -70,18 +85,24 @@ impl From for SiteVerifyResponse { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Deserialize)] struct RawSiteVerifyResponse { success: bool, #[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, } +/// 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"), @@ -92,30 +113,71 @@ 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 = hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector); - Self { http, secret } + Self { secret, http } } /// 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 + /// + /// # 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, ) -> 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)?)); + // 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 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)); let request = Request::builder() .method(Method::POST) @@ -127,13 +189,40 @@ 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 = 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(err.downcast::().map_or_else( + |_| TurnstileError::ResponseTooLarge, + |err| TurnstileError::HyperError(*err), + )) + } + }; + 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) @@ -141,26 +230,29 @@ 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")] +#[must_use] pub fn generate_idempotency_key() -> Option { Some(uuid::Uuid::new_v4()) } -// 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"))] +// 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#"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."# + 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."# ); diff --git a/src/test.rs b/src/test.rs index 03384a0..34f28b4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,11 +1,76 @@ -//! https://developers.cloudflare.com/turnstile/reference/testing/ -use crate::{ - error::{SiteVerifyError, TurnstileError}, - 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 +/// 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()), + #[cfg(feature = "idempotency")] + idempotency_key: None, + }; + + 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"}"# + ); +} + +/// 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()); +} + +#[cfg(feature = "network-tests")] #[tokio::test] async fn test_success() -> Result<()> { let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into()); @@ -17,11 +82,14 @@ 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(()) } +#[cfg(feature = "network-tests")] #[tokio::test] async fn test_fail() -> Result<()> { let client = TurnstileClient::new("2x0000000000000000000000000000000AA".to_string().into()); @@ -33,11 +101,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()); @@ -55,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(()) @@ -83,10 +160,9 @@ async fn test_integration() -> Result<()> { }) .await?; - assert!(validated.success); assert_eq!(validated.hostname, hostname); - println!("validated: {:#?}", validated); + println!("validated: {validated:#?}"); Ok(()) }