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        err
160    )]
161    pub async fn verify(
162        &self,
163        activity_tracker: &BoundActivityTracker,
164        http_client: &reqwest::Client,
165        site_hostname: &str,
166        config: Option<&CaptchaConfig>,
167    ) -> Result<(), Error> {
168        let Some(config) = config else {
169            if self.g_recaptcha_response.is_some()
170                || self.h_captcha_response.is_some()
171                || self.cf_turnstile_response.is_some()
172            {
173                return Err(Error::NoCaptchaConfigured);
174            }
175
176            return Ok(());
177        };
178
179        let remoteip = activity_tracker.ip();
180        let secret = &config.secret_key;
181
182        let span = tracing::Span::current();
183        span.record("captcha.service", tracing::field::debug(config.service));
184
185        let request = match (
186            config.service,
187            &self.g_recaptcha_response,
188            &self.h_captcha_response,
189            &self.cf_turnstile_response,
190        ) {
191            (_, None, None, None) => return Err(Error::MissingCaptchaResponse),
192
193            // reCAPTCHA v2
194            (CaptchaService::RecaptchaV2, Some(response), None, None) => http_client
195                .post(RECAPTCHA_VERIFY_URL)
196                .form(&VerificationRequest {
197                    secret,
198                    response,
199                    remoteip,
200                }),
201
202            // hCaptcha
203            (CaptchaService::HCaptcha, None, Some(response), None) => http_client
204                .post(HCAPTCHA_VERIFY_URL)
205                .form(&VerificationRequest {
206                    secret,
207                    response,
208                    remoteip,
209                }),
210
211            // Cloudflare Turnstile
212            (CaptchaService::CloudflareTurnstile, None, None, Some(response)) => http_client
213                .post(CF_TURNSTILE_VERIFY_URL)
214                .form(&VerificationRequest {
215                    secret,
216                    response,
217                    remoteip,
218                }),
219
220            _ => return Err(Error::CaptchaResponseMismatch),
221        };
222
223        let response: VerificationResponse = request
224            .send_traced()
225            .await?
226            .error_for_status()?
227            .json()
228            .await?;
229
230        if !response.success {
231            return Err(Error::InvalidCaptcha(
232                response.error_codes.unwrap_or_default(),
233            ));
234        }
235
236        // If the response is successful, we should have both the hostname and the
237        // challenge_ts
238        let Some(hostname) = response.hostname else {
239            return Err(Error::InvalidResponse);
240        };
241
242        let Some(challenge_ts) = response.challenge_ts else {
243            return Err(Error::InvalidResponse);
244        };
245
246        span.record("captcha.hostname", &hostname);
247        span.record("captcha.challenge_ts", &challenge_ts);
248
249        if hostname != site_hostname {
250            return Err(Error::HostnameMismatch {
251                expected: site_hostname.to_owned(),
252                got: hostname,
253            });
254        }
255
256        Ok(())
257    }
258}