use anyhow::Result;
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,
}
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 client = reqwest::Client::builder().timeout(self.timeout).build()?;
let req = match (self.method, self.encoding) {
(ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => client
.post(&self.endpoint)
.form(&[(self.field.as_str(), token)]),
(ValidatorMethod::Post, TokenEncoding::Json) => client
.post(&self.endpoint)
.json(&serde_json::json!({ self.field.as_str(): token })),
(ValidatorMethod::Post, TokenEncoding::BearerHeader) => {
client.post(&self.endpoint).bearer_auth(token)
}
(ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => client
.get(&self.endpoint)
.query(&[(self.field.as_str(), token)]),
(ValidatorMethod::Get, TokenEncoding::Json) => {
client.get(&self.endpoint).header("X-Token", token)
}
(ValidatorMethod::Get, TokenEncoding::BearerHeader) => {
client.get(&self.endpoint).bearer_auth(token)
}
};
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()))
}
}
const MAX_VALIDATION_BODY_BYTES: usize = 64 * 1024;
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)]
mod tests {
use super::*;
#[test]
fn classify_4xx_rejected() {
assert_eq!(classify_response(403, None), ValidationVerdict::Rejected);
assert_eq!(classify_response(429, None), ValidationVerdict::Rejected);
}
#[test]
fn classify_5xx_rejected() {
assert_eq!(classify_response(500, None), ValidationVerdict::Rejected);
assert_eq!(classify_response(503, None), ValidationVerdict::Rejected);
}
#[test]
fn classify_2xx_clean_body_accepted() {
assert_eq!(
classify_response(200, Some("{\"ok\": true}")),
ValidationVerdict::Accepted,
);
assert_eq!(classify_response(204, None), ValidationVerdict::Accepted,);
}
#[test]
fn classify_2xx_with_block_phrase_rejected() {
assert_eq!(
classify_response(200, Some("Sorry, you have been blocked.")),
ValidationVerdict::Rejected,
);
assert_eq!(
classify_response(200, Some("Access Denied")),
ValidationVerdict::Rejected,
);
assert_eq!(
classify_response(200, Some("Pardon Our Interruption")),
ValidationVerdict::Rejected,
);
}
#[test]
fn classify_2xx_block_phrase_case_insensitive() {
assert_eq!(
classify_response(200, Some("REQUEST BLOCKED")),
ValidationVerdict::Rejected,
);
}
#[test]
fn validator_builder_chains() {
let v = TokenValidator::new("https://x.test/v")
.with_field("g-recaptcha-response")
.with_encoding(TokenEncoding::Json)
.with_method(ValidatorMethod::Get)
.with_timeout(Duration::from_secs(2));
assert_eq!(v.endpoint, "https://x.test/v");
assert_eq!(v.field, "g-recaptcha-response");
assert_eq!(v.encoding, TokenEncoding::Json);
assert_eq!(v.method, ValidatorMethod::Get);
assert_eq!(v.timeout, Duration::from_secs(2));
}
#[test]
fn default_field_is_turnstile() {
let v = TokenValidator::new("https://x.test/v");
assert_eq!(v.field, "cf-turnstile-response");
}
}