Skip to main content

alibi_plugins/
captcha.rs

1//! CAPTCHA admission before endpoint parsing and authentication side effects.
2use alibi_core::error::codes;
3use alibi_core::{
4    AuthContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
5    utils::{
6        json::{JsValue, parse_value},
7        wildcard,
8    },
9};
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use std::{sync::Arc, time::Duration};
13
14const VERIFY_TIMEOUT: Duration = Duration::from_secs(10);
15const DEFAULT_ENDPOINTS: &[&str] = &[
16    "/sign-up/email",
17    "/sign-in/email",
18    "/request-password-reset",
19];
20
21/// Trusted application configuration for a remote verification service.
22#[derive(Clone, Debug)]
23pub struct CaptchaHttpOptions {
24    pub secret_key: String,
25    /// Override the provider endpoint, for example with an application-owned proxy.
26    pub site_verify_url: Option<url::Url>,
27}
28impl CaptchaHttpOptions {
29    #[must_use]
30    pub fn new(secret_key: impl Into<String>) -> Self {
31        Self {
32            secret_key: secret_key.into(),
33            site_verify_url: None,
34        }
35    }
36}
37
38#[derive(Clone, Debug)]
39pub struct TurnstileConfig {
40    pub http: CaptchaHttpOptions,
41    pub expected_action: Option<String>,
42    pub allowed_hostnames: Vec<String>,
43}
44impl TurnstileConfig {
45    #[must_use]
46    pub fn new(secret_key: impl Into<String>) -> Self {
47        Self {
48            http: CaptchaHttpOptions::new(secret_key),
49            expected_action: None,
50            allowed_hostnames: Vec::new(),
51        }
52    }
53}
54
55#[derive(Clone, Debug)]
56pub struct RecaptchaConfig {
57    pub http: CaptchaHttpOptions,
58    pub min_score: f64,
59    pub expected_action: Option<String>,
60    pub allowed_hostnames: Vec<String>,
61}
62impl RecaptchaConfig {
63    #[must_use]
64    pub fn new(secret_key: impl Into<String>) -> Self {
65        Self {
66            http: CaptchaHttpOptions::new(secret_key),
67            min_score: 0.5,
68            expected_action: None,
69            allowed_hostnames: Vec::new(),
70        }
71    }
72}
73
74#[derive(Clone, Debug)]
75pub struct SiteKeyCaptchaConfig {
76    pub http: CaptchaHttpOptions,
77    pub site_key: Option<String>,
78}
79impl SiteKeyCaptchaConfig {
80    #[must_use]
81    pub fn new(secret_key: impl Into<String>) -> Self {
82        Self {
83            http: CaptchaHttpOptions::new(secret_key),
84            site_key: None,
85        }
86    }
87}
88
89/// Full `BotID` result retained for an application's trusted validation callback.
90#[derive(Clone, Debug, Deserialize, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct BotIdVerification {
93    pub is_bot: bool,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub is_verified_bot: Option<bool>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub verified_bot_name: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub verified_bot_category: Option<String>,
100}
101
102#[async_trait]
103pub trait CheckBotId: Send + Sync {
104    async fn check(&self) -> AuthResult<BotIdVerification>;
105}
106
107#[async_trait]
108pub trait ValidateBotIdRequest: Send + Sync {
109    async fn validate(
110        &self,
111        request: &AuthRequest,
112        verification: &BotIdVerification,
113    ) -> AuthResult<bool>;
114}
115
116#[derive(Clone)]
117pub struct BotIdConfig {
118    pub check_bot_id: Arc<dyn CheckBotId>,
119    pub validate_request: Option<Arc<dyn ValidateBotIdRequest>>,
120}
121
122#[derive(Clone)]
123pub enum CaptchaProvider {
124    CloudflareTurnstile(TurnstileConfig),
125    GoogleRecaptcha(RecaptchaConfig),
126    HCaptcha(SiteKeyCaptchaConfig),
127    CaptchaFox(SiteKeyCaptchaConfig),
128    VercelBotId(BotIdConfig),
129}
130
131#[derive(Clone)]
132pub struct CaptchaConfig {
133    pub provider: CaptchaProvider,
134    /// An empty list protects the default signup, sign-in and password reset paths.
135    /// Patterns containing `*` support segment wildcards and `**` for descendants.
136    pub endpoints: Vec<String>,
137}
138impl CaptchaConfig {
139    #[must_use]
140    pub fn new(provider: CaptchaProvider) -> Self {
141        Self {
142            provider,
143            endpoints: Vec::new(),
144        }
145    }
146}
147
148pub struct CaptchaPlugin {
149    config: CaptchaConfig,
150    client: reqwest::Client,
151}
152impl CaptchaPlugin {
153    #[must_use]
154    pub fn new(config: CaptchaConfig) -> Self {
155        Self {
156            config,
157            client: reqwest::Client::new(),
158        }
159    }
160
161    /// Configure transport, proxy and TLS policy for the verification service.
162    #[must_use]
163    pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
164        self.client = client;
165        self
166    }
167
168    /// The verdict once verification completed, or `None` when it could not.
169    async fn verify(&self, request: &AuthRequest) -> Option<bool> {
170        if let CaptchaProvider::VercelBotId(options) = &self.config.provider {
171            return verify_bot_id(options.clone(), request.clone()).await;
172        }
173        let (http, endpoint) = match &self.config.provider {
174            CaptchaProvider::CloudflareTurnstile(options) => (
175                &options.http,
176                "https://challenges.cloudflare.com/turnstile/v0/siteverify",
177            ),
178            CaptchaProvider::GoogleRecaptcha(options) => (
179                &options.http,
180                "https://www.google.com/recaptcha/api/siteverify",
181            ),
182            CaptchaProvider::HCaptcha(options) => {
183                (&options.http, "https://api.hcaptcha.com/siteverify")
184            }
185            CaptchaProvider::CaptchaFox(options) => {
186                (&options.http, "https://api.captchafox.com/siteverify")
187            }
188            CaptchaProvider::VercelBotId(_) => return None,
189        };
190        let token = request.header("x-captcha-response")?;
191        let policy = request
192            .extensions()
193            .get::<alibi_core::config::IpAddressConfig>();
194        let ip = policy
195            .map_or_else(
196                || alibi_core::config::IpAddressConfig::default().resolve_ip(&request.headers),
197                |policy| policy.resolve_ip(&request.headers),
198            )
199            .filter(|ip| !ip.is_empty());
200        let endpoint = http
201            .site_verify_url
202            .as_ref()
203            .map_or(endpoint, url::Url::as_str);
204        let builder = self.client.post(endpoint);
205        let builder = match &self.config.provider {
206            CaptchaProvider::CloudflareTurnstile(_) => {
207                #[derive(Serialize)]
208                struct Body<'a> {
209                    secret: &'a str,
210                    response: &'a str,
211                    #[serde(skip_serializing_if = "Option::is_none")]
212                    remoteip: Option<&'a str>,
213                }
214                builder.json(&Body {
215                    secret: &http.secret_key,
216                    response: token,
217                    remoteip: ip.as_deref(),
218                })
219            }
220            CaptchaProvider::GoogleRecaptcha(_)
221            | CaptchaProvider::HCaptcha(_)
222            | CaptchaProvider::CaptchaFox(_) => {
223                let mut fields = vec![("secret", http.secret_key.as_str()), ("response", token)];
224                if let CaptchaProvider::HCaptcha(options) | CaptchaProvider::CaptchaFox(options) =
225                    &self.config.provider
226                    && let Some(site_key) =
227                        options.site_key.as_deref().filter(|key| !key.is_empty())
228                {
229                    fields.push(("sitekey", site_key));
230                }
231                if let Some(ip) = ip.as_deref() {
232                    let name = if matches!(self.config.provider, CaptchaProvider::CaptchaFox(_)) {
233                        "remoteIp"
234                    } else {
235                        "remoteip"
236                    };
237                    fields.push((name, ip));
238                }
239                builder.form(&fields)
240            }
241            CaptchaProvider::VercelBotId(_) => return None,
242        };
243        // betterFetch's abort deadline ends when response headers arrive;
244        // body decoding follows independently of that timer.
245        let response = tokio::time::timeout(VERIFY_TIMEOUT, builder.send())
246            .await
247            .ok()?
248            .ok()?;
249        if !response.status().is_success() {
250            return None;
251        }
252        let content_type = response
253            .headers()
254            .get(reqwest::header::CONTENT_TYPE)
255            .map(|value| value.to_str().unwrap_or_default());
256        let media_type = content_type
257            .and_then(|value| value.split(';').next())
258            .unwrap_or_default();
259        let json_media = media_type.to_ascii_lowercase();
260        let json = json_media
261            .strip_prefix("application/")
262            .is_some_and(|subtype| {
263                subtype == "json"
264                    || subtype.strip_suffix("+json").is_some_and(|prefix| {
265                        prefix.bytes().all(|byte| {
266                            byte.is_ascii_alphanumeric() || b"_!#$%&*.^`~-".contains(&byte)
267                        })
268                    })
269            });
270        let text_media = media_type.starts_with("text/")
271            || [
272                "image/svg",
273                "application/xml",
274                "application/xhtml",
275                "application/html",
276            ]
277            .contains(&media_type);
278        if content_type.is_some() && !json && !text_media {
279            // betterFetch returns a Blob for binary media; it cannot supply a
280            // verification success field even if its bytes happen to be JSON.
281            return Some(false);
282        }
283        let text = response.text().await.ok()?;
284        // betterFetch retains invalid JSON as text, so a truthy malformed reply
285        // fails verification rather than becoming a transport error.
286        let data = parse_value(&text).unwrap_or_else(|_| JsValue::String(text));
287        if !truthy(&data) {
288            return None;
289        }
290        if !data.get("success").is_some_and(truthy) {
291            return Some(false);
292        }
293        let (action, hostnames) = match &self.config.provider {
294            CaptchaProvider::CloudflareTurnstile(options) => {
295                (&options.expected_action, &options.allowed_hostnames)
296            }
297            CaptchaProvider::GoogleRecaptcha(options) => {
298                if data
299                    .get("score")
300                    .and_then(JsValue::as_f64)
301                    .is_some_and(|score| score < options.min_score)
302                {
303                    return Some(false);
304                }
305                (&options.expected_action, &options.allowed_hostnames)
306            }
307            CaptchaProvider::HCaptcha(_) | CaptchaProvider::CaptchaFox(_) => return Some(true),
308            CaptchaProvider::VercelBotId(_) => return None,
309        };
310        if action
311            .as_deref()
312            .filter(|action| !action.is_empty())
313            .is_some_and(|action| data.get("action").and_then(JsValue::as_str) != Some(action))
314        {
315            return Some(false);
316        }
317        Some(
318            hostnames.is_empty()
319                || data
320                    .get("hostname")
321                    .and_then(JsValue::as_str)
322                    .is_some_and(|hostname| hostnames.iter().any(|allowed| allowed == hostname)),
323        )
324    }
325}
326
327async fn verify_bot_id(options: BotIdConfig, request: AuthRequest) -> Option<bool> {
328    // Source races the promise without cancelling it. A detached task
329    // allows a trusted application callback to finish after the deadline.
330    let task = tokio::spawn(async move {
331        let verification = options.check_bot_id.check().await?;
332        match options.validate_request {
333            Some(callback) => callback.validate(&request, &verification).await,
334            None => Ok(!verification.is_bot),
335        }
336    });
337    tokio::time::timeout(VERIFY_TIMEOUT, task)
338        .await
339        .ok()?
340        .ok()?
341        .ok()
342}
343
344fn truthy(value: &JsValue) -> bool {
345    match value {
346        JsValue::Null => false,
347        JsValue::Bool(value) => *value,
348        JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
349        JsValue::String(value) => !value.is_empty(),
350        JsValue::Date(_) | JsValue::Array(_) | JsValue::Object(_) => true,
351    }
352}
353
354fn unknown_error() -> AuthResponse {
355    error_response(500, "Something went wrong", codes::UNKNOWN_ERROR)
356}
357
358fn error_response(status: u16, message: &str, code: &str) -> AuthResponse {
359    AuthResponse::text(
360        status,
361        format!("{{\"message\":\"{message}\",\"code\":\"{code}\"}}"),
362    )
363    .with_header("content-type", "application/json")
364}
365
366fn normalized_path(path: &str, base: &str) -> String {
367    let path = path.strip_prefix(base).unwrap_or(path);
368    let mut result = String::from("/");
369    for character in path.chars() {
370        if character != '/' || !result.ends_with('/') {
371            result.push(character);
372        }
373    }
374    if result.len() > 1 && result.ends_with('/') {
375        _ = result.pop();
376    }
377    result
378}
379
380fn path_matches(pattern: &str, path: &str) -> Result<bool, regex::Error> {
381    if !pattern.contains('*') {
382        return Ok(pattern == path);
383    }
384    wildcard::compile(pattern).map(|compiled| compiled.is_match(path))
385}
386
387#[async_trait]
388impl<S: AuthSchema> AuthPlugin<S> for CaptchaPlugin {
389    route_openapi_metadata!(S);
390
391    fn name(&self) -> &'static str {
392        "captcha"
393    }
394    fn routes(&self) -> Vec<AuthRoute> {
395        Vec::new()
396    }
397    async fn on_http_request(
398        &self,
399        request: &AuthRequest,
400        context: &AuthContext<S>,
401    ) -> AuthResult<Option<AuthResponse>> {
402        let path = normalized_path(
403            request.url().map_or_else(|| request.path(), url::Url::path),
404            &context.config.base_path,
405        );
406        let protected = if self.config.endpoints.is_empty() {
407            DEFAULT_ENDPOINTS.iter().any(|endpoint| *endpoint == path)
408        } else {
409            let mut matched = false;
410            for endpoint in &self.config.endpoints {
411                match path_matches(endpoint, &path) {
412                    Ok(true) => {
413                        matched = true;
414                        break;
415                    }
416                    Ok(false) => {}
417                    Err(_) => return Ok(Some(unknown_error())),
418                }
419            }
420            matched
421        };
422        if !protected {
423            return Ok(None);
424        }
425        let secret = match &self.config.provider {
426            CaptchaProvider::CloudflareTurnstile(options) => Some(&options.http.secret_key),
427            CaptchaProvider::GoogleRecaptcha(options) => Some(&options.http.secret_key),
428            CaptchaProvider::HCaptcha(options) | CaptchaProvider::CaptchaFox(options) => {
429                Some(&options.http.secret_key)
430            }
431            CaptchaProvider::VercelBotId(_) => None,
432        };
433        if secret.is_some_and(String::is_empty) {
434            return Ok(Some(unknown_error()));
435        }
436        if secret.is_some()
437            && request
438                .header("x-captcha-response")
439                .is_none_or(String::is_empty)
440        {
441            return Ok(Some(error_response(
442                400,
443                "Missing CAPTCHA response",
444                codes::MISSING_RESPONSE,
445            )));
446        }
447        Ok(match self.verify(request).await {
448            Some(true) => None,
449            Some(false) => Some(error_response(
450                403,
451                "Captcha verification failed",
452                codes::VERIFICATION_FAILED,
453            )),
454            None => Some(unknown_error()),
455        })
456    }
457    async fn on_request(
458        &self,
459        _: &AuthRequest,
460        _: &AuthContext<S>,
461    ) -> AuthResult<Option<AuthResponse>> {
462        Ok(None)
463    }
464}