Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "cf-turnstile"
version = "0.3.0"
edition = "2021"
edition = "2024"
description = "A Rust client for Cloudflare Turnstile"
homepage = "https://github.com/sycertech/cf-turnstile"
repository = "https://github.com/sycertech/cf-turnstile"
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = TurnstileClient::new("my-secret".to_string().into());

let validated = client
.siteverify(SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
})
.siteverify(
SiteVerifyRequest {
response: "my-widget-response".to_string(),
..Default::default()
},
Some(&["example.com"]),
)
.await?;

// `siteverify` returns `Err` unless Cloudflare verified the token.
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ pub enum TurnstileError {
#[error("Turnstile API returned HTTP status {0}")]
UnexpectedStatus(hyper::StatusCode),

/// The Turnstile API returned a hostname that was not allowed
#[error("Hostname {0} did not match allowed list of hostnames")]
InvalidHostname(String),

/// The Turnstile API rejected the token but returned no error code.
#[error("Turnstile rejected the token without returning an error code")]
VerificationFailed,
Expand Down
24 changes: 19 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ use connector::Connector;
use error::{SiteVerifyErrors, TurnstileError};
use http_body_util::{BodyExt, Full, Limited};
use hyper::{
Method, Request,
body::Bytes,
header::{CONTENT_TYPE, USER_AGENT},
Method, Request,
};
use hyper_util::{client::legacy::Client as HyperClient, rt::TokioExecutor};
use secrecy::{ExposeSecret, SecretString};
Expand Down Expand Up @@ -124,6 +124,11 @@ impl TurnstileClient {

/// Verify a Cloudflare Turnstile response.
///
/// `valid_hostnames` is an optional list of hostnames to verify against. The function
/// will error if the hostname returned by the Turnstile API does not match any of the
/// provided hostnames.
/// When it is None, the hostname is not verified.
///
/// # Timeouts
///
/// No timeout is applied, and hyper's client has none of its own, so a stalled
Expand All @@ -140,9 +145,10 @@ impl TurnstileClient {
/// # ) -> Option<Result<SiteVerifyResponse, TurnstileError>> {
/// use std::time::Duration;
///
/// tokio::time::timeout(Duration::from_secs(5), client.siteverify(request))
/// .await
/// .ok()
/// tokio::time::timeout(
/// Duration::from_secs(5),
/// client.siteverify(request, Some(&["example.com"]))
/// ).await.ok()
/// # }
/// ```
///
Expand All @@ -167,6 +173,7 @@ impl TurnstileClient {
pub async fn siteverify(
&self,
request: SiteVerifyRequest,
valid_hostnames: Option<&[&str]>,
) -> Result<SiteVerifyResponse, TurnstileError> {
let body = SiteVerifyBody {
secret: self.secret.expose_secret(),
Expand Down Expand Up @@ -207,7 +214,7 @@ impl TurnstileClient {
return Err(err.downcast::<hyper::Error>().map_or_else(
|_| TurnstileError::ResponseTooLarge,
|err| TurnstileError::HyperError(*err),
))
));
}
};

Expand All @@ -223,6 +230,13 @@ impl TurnstileClient {
return Err(TurnstileError::VerificationFailed);
}

if let Some(valid_hostnames) = valid_hostnames
&& let Some(ref body_hostname) = body.hostname
&& !valid_hostnames.contains(&body_hostname.as_str())
{
return Err(TurnstileError::InvalidHostname(body_hostname.clone()));
}

let transformed = SiteVerifyResponse::from(body);

Ok(transformed)
Expand Down
94 changes: 74 additions & 20 deletions src/test.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! <https://developers.cloudflare.com/turnstile/reference/testing/>
use crate::{error::SiteVerifyError, RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest};
use crate::{RawSiteVerifyResponse, SiteVerifyBody, SiteVerifyRequest, error::SiteVerifyError};

#[cfg(feature = "network-tests")]
use crate::error::TurnstileError;
#[cfg(any(feature = "network-tests", feature = "integration"))]
use crate::TurnstileClient;
#[cfg(feature = "network-tests")]
use crate::error::TurnstileError;

#[cfg(any(feature = "network-tests", feature = "integration"))]
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync + 'static>>;
Expand Down Expand Up @@ -76,10 +76,35 @@ async fn test_success() -> Result<()> {
let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into());

let validated = client
.siteverify(SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
})
.siteverify(
SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
},
Some(&["example.com"]),
)
.await?;

// `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_success_with_hostname() -> Result<()> {
let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into());

let validated = client
.siteverify(
SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
},
Some(&["example.com"]),
)
.await?;

// `siteverify` returns `Err` unless the token was verified, so reaching this
Expand All @@ -89,16 +114,39 @@ async fn test_success() -> Result<()> {
Ok(())
}

#[cfg(feature = "network-tests")]
#[tokio::test]
async fn test_reject_invalid_hostname() -> Result<()> {
let client = TurnstileClient::new("1x0000000000000000000000000000000AA".to_string().into());

let result = client
.siteverify(
SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
},
Some(&["evil.com"]),
)
.await;

std::assert_matches!(result.err(), Some(TurnstileError::InvalidHostname(_)));

Ok(())
}

#[cfg(feature = "network-tests")]
#[tokio::test]
async fn test_fail() -> Result<()> {
let client = TurnstileClient::new("2x0000000000000000000000000000000AA".to_string().into());

let validated = client
.siteverify(SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
})
.siteverify(
SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
},
Some(&["example.com"]),
)
.await;

// Assert the API rejected the token, not merely that something went wrong:
Expand All @@ -120,10 +168,13 @@ async fn test_token_already_spent() -> Result<()> {
let client = TurnstileClient::new("3x0000000000000000000000000000000AA".to_string().into());

let validated = client
.siteverify(SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
})
.siteverify(
SiteVerifyRequest {
response: "myresponse".to_string(),
..Default::default()
},
Some(&["example.com"]),
)
.await;

assert!(validated.is_err());
Expand Down Expand Up @@ -153,11 +204,14 @@ async fn test_integration() -> Result<()> {
let client = TurnstileClient::new(secret_key.into());

let validated = client
.siteverify(SiteVerifyRequest {
response,
idempotency_key,
..Default::default()
})
.siteverify(
SiteVerifyRequest {
response,
idempotency_key,
..Default::default()
},
Some(&["example.com"]),
)
.await?;

assert_eq!(validated.hostname, hostname);
Expand Down