Skip to main content

alibi_api/plugins/
captcha.rs

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