1use anyhow::Result;
46use reqwest::header::{HeaderValue, AUTHORIZATION, CONTENT_TYPE};
47use serde::{Deserialize, Serialize};
48use std::time::Duration;
49
50use crate::solver::oracle::BLOCK_PHRASES;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum ValidationVerdict {
60 Accepted,
61 Rejected,
62 Inconclusive,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ValidatorMethod {
68 Post,
69 Get,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TokenEncoding {
78 FormUrlEncoded,
80 Json,
82 BearerHeader,
86}
87
88#[derive(Debug, Clone)]
90pub struct TokenValidator {
91 pub endpoint: String,
92 pub method: ValidatorMethod,
93 pub encoding: TokenEncoding,
94 pub field: String,
96 pub timeout: Duration,
98}
99
100struct ValidationRequest {
101 method: &'static str,
102 url: String,
103 headers: Vec<(String, String)>,
104 body: Option<Vec<u8>>,
105}
106
107impl TokenValidator {
108 pub fn new(endpoint: impl Into<String>) -> Self {
111 Self {
112 endpoint: endpoint.into(),
113 method: ValidatorMethod::Post,
114 encoding: TokenEncoding::FormUrlEncoded,
115 field: "cf-turnstile-response".into(),
116 timeout: Duration::from_secs(10),
117 }
118 }
119
120 pub fn with_field(mut self, field: impl Into<String>) -> Self {
121 self.field = field.into();
122 self
123 }
124
125 pub fn with_encoding(mut self, encoding: TokenEncoding) -> Self {
126 self.encoding = encoding;
127 self
128 }
129
130 pub fn with_method(mut self, method: ValidatorMethod) -> Self {
131 self.method = method;
132 self
133 }
134
135 pub fn with_timeout(mut self, timeout: Duration) -> Self {
136 self.timeout = timeout;
137 self
138 }
139
140 pub async fn validate(&self, token: &str) -> Result<ValidationVerdict> {
148 let Some(request) = self.validation_request(token) else {
149 return Ok(ValidationVerdict::Inconclusive);
150 };
151
152 #[cfg(feature = "tls-impersonate")]
153 {
154 return match crate::waf_gate::send_validation(
155 request.method,
156 &request.url,
157 request.headers,
158 request.body,
159 self.timeout,
160 )
161 .await
162 {
163 Ok((status, body)) => Ok(classify_response(status, body.as_deref())),
164 Err(()) => Ok(ValidationVerdict::Inconclusive),
165 };
166 }
167
168 #[cfg(not(feature = "tls-impersonate"))]
169 {
170 let client = crate::http_client::timed_client(self.timeout)?;
171
172 let mut req = match request.method {
173 "POST" => client.post(&request.url),
174 "GET" => client.get(&request.url),
175 _ => return Ok(ValidationVerdict::Inconclusive),
176 };
177 for (name, value) in request.headers {
178 req = req.header(name, value);
179 }
180 if let Some(body) = request.body {
181 req = req.body(body);
182 }
183
184 let response = match req.send().await {
185 Ok(r) => r,
186 Err(_) => return Ok(ValidationVerdict::Inconclusive),
187 };
188
189 let status = response.status().as_u16();
190 let body = read_capped_body(response, MAX_VALIDATION_BODY_BYTES).await;
197 Ok(classify_response(status, body.as_deref()))
198 }
199 }
200
201 fn validation_request(&self, token: &str) -> Option<ValidationRequest> {
202 let mut url = reqwest::Url::parse(&self.endpoint).ok()?;
203 let method = match self.method {
204 ValidatorMethod::Post => "POST",
205 ValidatorMethod::Get => "GET",
206 };
207 let mut headers = validation_default_header_pairs();
208 let mut body = None;
209
210 match (self.method, self.encoding) {
211 (ValidatorMethod::Post, TokenEncoding::FormUrlEncoded) => {
212 headers.push((
213 CONTENT_TYPE.as_str().to_string(),
214 "application/x-www-form-urlencoded".to_string(),
215 ));
216 body = Some(
217 url::form_urlencoded::Serializer::new(String::new())
218 .append_pair(&self.field, token)
219 .finish()
220 .into_bytes(),
221 );
222 }
223 (ValidatorMethod::Post, TokenEncoding::Json) => {
224 headers.push((
225 CONTENT_TYPE.as_str().to_string(),
226 "application/json".to_string(),
227 ));
228 body = Some(
229 serde_json::to_vec(&serde_json::json!({
230 self.field.as_str(): token
231 }))
232 .ok()?,
233 );
234 }
235 (ValidatorMethod::Post, TokenEncoding::BearerHeader) => {
236 headers.push(authorization_header_pair(token)?);
237 }
238 (ValidatorMethod::Get, TokenEncoding::FormUrlEncoded) => {
239 url.query_pairs_mut().append_pair(&self.field, token);
240 }
241 (ValidatorMethod::Get, TokenEncoding::Json) => {
242 headers.push(("X-Token".to_string(), header_value_string(token)?));
243 }
244 (ValidatorMethod::Get, TokenEncoding::BearerHeader) => {
245 headers.push(authorization_header_pair(token)?);
246 }
247 }
248
249 Some(ValidationRequest {
250 method,
251 url: url.to_string(),
252 headers,
253 body,
254 })
255 }
256}
257
258#[cfg(not(feature = "tls-impersonate"))]
268const MAX_VALIDATION_BODY_BYTES: usize = crate::waf_gate::VALIDATION_BODY_CAP;
269
270fn validation_default_header_pairs() -> Vec<(String, String)> {
271 guise::http::default_browser_request_headers_without_compression(
272 guise::http::BrowserRequestKind::SameOriginFetch,
273 )
274 .into_iter()
275 .map(|header| (header.name.to_string(), header.value))
276 .collect()
277}
278
279fn authorization_header_pair(token: &str) -> Option<(String, String)> {
280 Some((
281 AUTHORIZATION.as_str().to_string(),
282 header_value_string(&format!("Bearer {token}"))?,
283 ))
284}
285
286fn header_value_string(raw: &str) -> Option<String> {
287 let value = HeaderValue::from_str(raw).ok()?;
288 value.to_str().ok().map(str::to_string)
289}
290
291#[cfg(not(feature = "tls-impersonate"))]
294async fn read_capped_body(mut response: reqwest::Response, max: usize) -> Option<String> {
295 let mut buf: Vec<u8> = Vec::new();
298 loop {
299 let chunk = response.chunk().await.ok().flatten();
300 match chunk {
301 Some(bytes) => {
302 if buf.len() + bytes.len() > max {
303 let remaining = max.saturating_sub(buf.len());
304 buf.extend_from_slice(&bytes[..remaining]);
305 break;
306 }
307 buf.extend_from_slice(&bytes);
308 }
309 None => break,
310 }
311 }
312 let mut end = buf.len();
317 for _ in 0..3 {
318 if std::str::from_utf8(&buf[..end]).is_ok() {
319 break;
320 }
321 if end == 0 {
322 break;
323 }
324 end -= 1;
325 }
326 Some(String::from_utf8_lossy(&buf[..end]).into_owned())
327}
328
329pub fn classify_response(status: u16, body: Option<&str>) -> ValidationVerdict {
332 if !(200..300).contains(&status) {
333 return ValidationVerdict::Rejected;
334 }
335 if let Some(body) = body {
336 let lower = body.to_lowercase();
337 if BLOCK_PHRASES.iter().any(|p| lower.contains(p)) {
338 return ValidationVerdict::Rejected;
339 }
340 }
341 ValidationVerdict::Accepted
342}
343
344#[cfg(test)]
345#[path = "token_oracle/tests.rs"]
346mod tests;