Skip to main content

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