mas_handlers/
captcha.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use std::net::IpAddr;
8
9use mas_data_model::{CaptchaConfig, CaptchaService};
10use mas_http::RequestBuilderExt as _;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14use crate::BoundActivityTracker;
15
16// https://developers.google.com/recaptcha/docs/verify#api_request
17const RECAPTCHA_VERIFY_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
18
19// https://docs.hcaptcha.com/#verify-the-user-response-server-side
20const HCAPTCHA_VERIFY_URL: &str = "https://api.hcaptcha.com/siteverify";
21
22// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
23const CF_TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
24
25#[derive(Debug, Error)]
26pub enum Error {
27    #[error("A CAPTCHA response was expected, but none was provided")]
28    MissingCaptchaResponse,
29
30    #[error("A CAPTCHA response was provided, but no CAPTCHA provider is configured")]
31    NoCaptchaConfigured,
32
33    #[error("The CAPTCHA response provided is not valid for the configured service")]
34    CaptchaResponseMismatch,
35
36    #[error("The CAPTCHA response provided is invalid: {0:?}")]
37    InvalidCaptcha(Vec<ErrorCode>),
38
39    #[error("The CAPTCHA provider returned an invalid response")]
40    InvalidResponse,
41
42    #[error(
43        "The hostname in the CAPTCHA response ({got:?}) does not match the site hostname ({expected:?})"
44    )]
45    HostnameMismatch { expected: String, got: String },
46
47    #[error("The CAPTCHA provider returned an error")]
48    RequestFailed(#[from] reqwest::Error),
49}
50
51#[allow(clippy::struct_field_names)]
52#[derive(Debug, Deserialize, Default)]
53#[serde(rename_all = "kebab-case")]
54pub struct Form {
55    g_recaptcha_response: Option<String>,
56    h_captcha_response: Option<String>,
57    cf_turnstile_response: Option<String>,
58}
59
60#[derive(Debug, Serialize)]
61struct VerificationRequest<'a> {
62    secret: &'a str,
63    response: &'a str,
64    remoteip: Option<IpAddr>,
65}
66
67#[derive(Debug, Deserialize)]
68struct VerificationResponse {
69    success: bool,
70    #[serde(rename = "error-codes")]
71    error_codes: Option<Vec<ErrorCode>>,
72
73    challenge_ts: Option<String>,
74    hostname: Option<String>,
75}
76
77#[derive(Debug, Deserialize, Clone, Copy)]
78#[serde(rename_all = "kebab-case")]
79pub enum ErrorCode {
80    /// The secret parameter is missing.
81    ///
82    /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
83    MissingInputSecret,
84
85    /// The secret parameter is invalid or malformed.
86    ///
87    /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
88    InvalidInputSecret,
89
90    /// The response parameter is missing.
91    ///
92    /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
93    MissingInputResponse,
94
95    /// The response parameter is invalid or malformed.
96    ///
97    /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
98    InvalidInputResponse,
99
100    /// The widget ID extracted from the parsed site secret key was invalid or
101    /// did not exist.
102    ///
103    /// Used by Cloudflare Turnstile
104    InvalidWidgetId,
105
106    /// The secret extracted from the parsed site secret key was invalid.
107    ///
108    /// Used by Cloudflare Turnstile
109    InvalidParsedSecret,
110
111    /// The request is invalid or malformed.
112    ///
113    /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA
114    BadRequest,
115
116    /// The remoteip parameter is missing.
117    ///
118    /// Used by hCaptcha
119    MissingRemoteip,
120
121    /// The remoteip parameter is not a valid IP address or blinded value.
122    ///
123    /// Used by hCaptcha
124    InvalidRemoteip,
125
126    /// The response parameter has already been checked, or has another issue.
127    ///
128    /// Used by hCaptcha
129    InvalidOrAlreadySeenResponse,
130
131    /// You have used a testing sitekey but have not used its matching secret.
132    ///
133    /// Used by hCaptcha
134    NotUsingDummyPasscode,
135
136    /// The sitekey is not registered with the provided secret.
137    ///
138    /// Used by hCaptcha
139    SitekeySecretMismatch,
140
141    /// The response is no longer valid: either is too old or has been used
142    /// previously.
143    ///
144    /// Used by Cloudflare Turnstile, reCAPTCHA
145    TimeoutOrDisplicate,
146
147    /// An internal error happened while validating the response. The request
148    /// can be retried.
149    ///
150    /// Used by Cloudflare Turnstile
151    InternalError,
152}
153
154impl Form {
155    #[tracing::instrument(
156        skip_all,
157        name = "captcha.verify",
158        fields(captcha.hostname, captcha.challenge_ts, captcha.service),
159    )]
160    pub async fn verify(
161        &self,
162        activity_tracker: &BoundActivityTracker,
163        http_client: &reqwest::Client,
164        site_hostname: &str,
165        config: Option<&CaptchaConfig>,
166    ) -> Result<(), Error> {
167        let Some(config) = config else {
168            if self.g_recaptcha_response.is_some()
169                || self.h_captcha_response.is_some()
170                || self.cf_turnstile_response.is_some()
171            {
172                return Err(Error::NoCaptchaConfigured);
173            }
174
175            return Ok(());
176        };
177
178        let remoteip = activity_tracker.ip();
179        let secret = &config.secret_key;
180
181        let span = tracing::Span::current();
182        span.record("captcha.service", tracing::field::debug(config.service));
183
184        let request = match (
185            config.service,
186            &self.g_recaptcha_response,
187            &self.h_captcha_response,
188            &self.cf_turnstile_response,
189        ) {
190            (_, None, None, None) => return Err(Error::MissingCaptchaResponse),
191
192            // reCAPTCHA v2
193            (CaptchaService::RecaptchaV2, Some(response), None, None) => http_client
194                .post(RECAPTCHA_VERIFY_URL)
195                .form(&VerificationRequest {
196                    secret,
197                    response,
198                    remoteip,
199                }),
200
201            // hCaptcha
202            (CaptchaService::HCaptcha, None, Some(response), None) => http_client
203                .post(HCAPTCHA_VERIFY_URL)
204                .form(&VerificationRequest {
205                    secret,
206                    response,
207                    remoteip,
208                }),
209
210            // Cloudflare Turnstile
211            (CaptchaService::CloudflareTurnstile, None, None, Some(response)) => http_client
212                .post(CF_TURNSTILE_VERIFY_URL)
213                .form(&VerificationRequest {
214                    secret,
215                    response,
216                    remoteip,
217                }),
218
219            _ => return Err(Error::CaptchaResponseMismatch),
220        };
221
222        let response: VerificationResponse = request
223            .send_traced()
224            .await?
225            .error_for_status()?
226            .json()
227            .await?;
228
229        if !response.success {
230            return Err(Error::InvalidCaptcha(
231                response.error_codes.unwrap_or_default(),
232            ));
233        }
234
235        // If the response is successful, we should have both the hostname and the
236        // challenge_ts
237        let Some(hostname) = response.hostname else {
238            return Err(Error::InvalidResponse);
239        };
240
241        let Some(challenge_ts) = response.challenge_ts else {
242            return Err(Error::InvalidResponse);
243        };
244
245        span.record("captcha.hostname", &hostname);
246        span.record("captcha.challenge_ts", &challenge_ts);
247
248        if hostname != site_hostname {
249            return Err(Error::HostnameMismatch {
250                expected: site_hostname.to_owned(),
251                got: hostname,
252            });
253        }
254
255        Ok(())
256    }
257}