use anyhow::Result;
use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::solver::oracle::BLOCK_PHRASES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ValidationVerdict {
Accepted,
Rejected,
Inconclusive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidatorMethod {
Post,
Get,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenEncoding {
FormUrlEncoded,
Json,
BearerHeader,
}
#[derive(Debug, Clone)]
pub struct TokenValidator {
pub endpoint: String,
pub method: ValidatorMethod,
pub encoding: TokenEncoding,
pub field: String,
pub timeout: Duration,
}
struct ValidationRequest {
method: &'static str,
url: String,
headers: Vec<(String, String)>,
body: Option<Vec<u8>>,
}
impl TokenValidator {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
method: ValidatorMethod::Post,
encoding: TokenEncoding::FormUrlEncoded,
field: "cf-turnstile-response".into(),
timeout: Duration::from_secs(10),
}
}
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = field.into();
self
}
pub fn with_encoding(mut self, encoding: TokenEncoding) -> Self {
self.encoding = encoding;
self
}
pub fn with_method(mut self, method: ValidatorMethod) -> Self {
self.method = method;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn validate(&self, token: &str) -> Result<ValidationVerdict> {
let Some(request) = self.validation_request(token) else {
return Ok(ValidationVerdict::Inconclusive);
};
#[cfg(feature = "tls-impersonate")]
{
return match crate::waf_gate::send_validation(
request.method,
&request.url,
request.headers,
request.body,
self.timeout,
)
.await
{
Ok((status, body)) => Ok(classify_response(status, body.as_deref())),
Err(()) => Ok(ValidationVerdict::Inconclusive),
};
}
#[cfg(not(feature = "tls-impersonate"))]
{
let client = crate::http_client::timed_client(self.timeout)?;
let mut req = match request.method {
"POST" => client.post(&request.url),
"GET" => client.get(&request.url),
_ => return Ok(ValidationVerdict::Inconclusive),
};
for (name, value) in request.headers {
req = req.header(name, value);
}
if let Some(body) = request.body {
req = req.body(body);
}
let response = match req.send().await {
Ok(r) => r,
Err(_) => return Ok(ValidationVerdict::Inconclusive),
};
let status = response.status().as_u16();
let body = read_capped_body(response, MAX_VALIDATION_BODY_BYTES).await;
Ok(classify_response(status, body.as_deref()))
}
}
fn validation_request(&self, token: &str) -> Option<ValidationRequest> {
let mut url = reqwest::Url::parse(&self.endpoint).ok()?;
let method = match self.method {
ValidatorMethod::Post => "POST",
ValidatorMethod::Get => "GET",
};
let mut headers = validation_default_header_pairs();
let mut body = None;
match (self.method, self.encoding) {
(ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => {
headers.push((
CONTENT_TYPE.as_str().to_string(),
"application/x-www-form-urlencoded".to_string(),
));
body = Some(
url::form_urlencoded::Serializer::new(String::new())
.append_pair(&self.field, token)
.finish()
.into_bytes(),
);
}
(ValidatorMethod::Post, TokenEncoding::Json) => {
headers.push((
CONTENT_TYPE.as_str().to_string(),
"application/json".to_string(),
));
body = Some(
serde_json::to_vec(&serde_json::json!({
self.field.as_str(): token
}))
.ok()?,
);
}
(ValidatorMethod::Post, TokenEncoding::BearerHeader) => {
headers.push(authorization_header_pair(token)?);
}
(ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => {
url.query_pairs_mut().append_pair(&self.field, token);
}
(ValidatorMethod::Get, TokenEncoding::Json) => {
headers.push(("X-Token".to_string(), header_value_string(token)?));
}
(ValidatorMethod::Get, TokenEncoding::BearerHeader) => {
headers.push(authorization_header_pair(token)?);
}
}
Some(ValidationRequest {
method,
url: url.to_string(),
headers,
body,
})
}
}
#[cfg(not(feature = "tls-impersonate"))]
const MAX_VALIDATION_BODY_BYTES: usize = crate::waf_gate::VALIDATION_BODY_CAP;
fn validation_default_header_pairs() -> Vec<(String, String)> {
guise::http::default_browser_request_headers_without_compression(
guise::http::BrowserRequestKind::SameOriginFetch,
)
.into_iter()
.map(|header| (header.name.to_string(), header.value))
.collect()
}
fn authorization_header_pair(token: &str) -> Option<(String, String)> {
Some((
AUTHORIZATION.as_str().to_string(),
header_value_string(&format!("Bearer {token}"))?,
))
}
fn header_value_string(raw: &str) -> Option<String> {
let value = HeaderValue::from_str(raw).ok()?;
value.to_str().ok().map(str::to_string)
}
#[cfg(not(feature = "tls-impersonate"))]
async fn read_capped_body(mut response: reqwest::Response, max: usize) -> Option<String> {
let mut buf: Vec<u8> = Vec::new();
loop {
let chunk = response.chunk().await.ok().flatten();
match chunk {
Some(bytes) => {
if buf.len() + bytes.len() > max {
let remaining = max.saturating_sub(buf.len());
buf.extend_from_slice(&bytes[..remaining]);
break;
}
buf.extend_from_slice(&bytes);
}
None => break,
}
}
let mut end = buf.len();
for _ in 0..3 {
if std::str::from_utf8(&buf[..end]).is_ok() {
break;
}
if end == 0 {
break;
}
end -= 1;
}
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
pub fn classify_response(status: u16, body: Option<&str>) -> ValidationVerdict {
if !(200..300).contains(&status) {
return ValidationVerdict::Rejected;
}
if let Some(body) = body {
let lower = body.to_lowercase();
if BLOCK_PHRASES.iter().any(|p| lower.contains(p)) {
return ValidationVerdict::Rejected;
}
}
ValidationVerdict::Accepted
}
#[cfg(test)]
#[path = "token_oracle/tests.rs"]
mod tests;