diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 207c4380..8e3e0ccb 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -16,7 +16,7 @@ use chrono::Duration; use url::Url; /// Which Captcha service is being used -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum CaptchaService { RecaptchaV2, CloudflareTurnstile, diff --git a/crates/handlers/src/captcha.rs b/crates/handlers/src/captcha.rs new file mode 100644 index 00000000..6fe7c116 --- /dev/null +++ b/crates/handlers/src/captcha.rs @@ -0,0 +1,280 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::net::IpAddr; + +use axum::BoxError; +use hyper::Request; +use mas_axum_utils::http_client_factory::HttpClientFactory; +use mas_data_model::{CaptchaConfig, CaptchaService}; +use mas_http::HttpServiceExt; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tower::{Service, ServiceExt}; + +use crate::BoundActivityTracker; + +// https://developers.google.com/recaptcha/docs/verify#api_request +const RECAPTCHA_VERIFY_URL: &str = "https://www.google.com/recaptcha/api/siteverify"; + +// https://docs.hcaptcha.com/#verify-the-user-response-server-side +const HCAPTCHA_VERIFY_URL: &str = "https://api.hcaptcha.com/siteverify"; + +// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ +const CF_TURNSTILE_VERIFY_URL: &str = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +#[derive(Debug, Error)] +pub enum Error { + #[error("A CAPTCHA response was expected, but none was provided")] + MissingCaptchaResponse, + + #[error("A CAPTCHA response was provided, but no CAPTCHA provider is configured")] + NoCaptchaConfigured, + + #[error("The CAPTCHA response provided is not valid for the configured service")] + CaptchaResponseMismatch, + + #[error("The CAPTCHA response provided is invalid: {0:?}")] + InvalidCaptcha(Vec), + + #[error("The CAPTCHA provider returned an invalid response")] + InvalidResponse, + + #[error("The hostname in the CAPTCHA response ({got:?}) does not match the site hostname ({expected:?})")] + HostnameMismatch { expected: String, got: String }, + + #[error("The CAPTCHA provider returned an error")] + RequestFailed(#[source] BoxError), +} + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct Form { + g_recaptcha_response: Option, + h_captcha_response: Option, + cf_turnstile_response: Option, +} + +#[derive(Debug, Serialize)] +struct VerificationRequest<'a> { + secret: &'a str, + response: &'a str, + remoteip: Option, +} + +#[derive(Debug, Deserialize)] +struct VerificationResponse { + success: bool, + #[serde(rename = "error-codes")] + error_codes: Option>, + + challenge_ts: Option, + hostname: Option, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum ErrorCode { + /// The secret parameter is missing. + /// + /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA + MissingInputSecret, + + /// The secret parameter is invalid or malformed. + /// + /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA + InvalidInputSecret, + + /// The response parameter is missing. + /// + /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA + MissingInputResponse, + + /// The response parameter is invalid or malformed. + /// + /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA + InvalidInputResponse, + + /// The widget ID extracted from the parsed site secret key was invalid or + /// did not exist. + /// + /// Used by Cloudflare Turnstile + InvalidWidgetId, + + /// The secret extracted from the parsed site secret key was invalid. + /// + /// Used by Cloudflare Turnstile + InvalidParsedSecret, + + /// The request is invalid or malformed. + /// + /// Used by Cloudflare Turnstile, hCaptcha, reCAPTCHA + BadRequest, + + /// The remoteip parameter is missing. + /// + /// Used by hCaptcha + MissingRemoteip, + + /// The remoteip parameter is not a valid IP address or blinded value. + /// + /// Used by hCaptcha + InvalidRemoteip, + + /// The response parameter has already been checked, or has another issue. + /// + /// Used by hCaptcha + InvalidOrAlreadySeenResponse, + + /// You have used a testing sitekey but have not used its matching secret. + /// + /// Used by hCaptcha + NotUsingDummyPasscode, + + /// The sitekey is not registered with the provided secret. + /// + /// Used by hCaptcha + SitekeySecretMismatch, + + /// The response is no longer valid: either is too old or has been used + /// previously. + /// + /// Used by Cloudflare Turnstile, reCAPTCHA + TimeoutOrDisplicate, + + /// An internal error happened while validating the response. The request + /// can be retried. + /// + /// Used by Cloudflare Turnstile + InternalError, +} + +impl Form { + #[tracing::instrument( + skip_all, + name = "captcha.verify", + fields(captcha.hostname, captcha.challenge_ts, captcha.service), + err + )] + pub async fn verify( + &self, + activity_tracker: &BoundActivityTracker, + http_client_factory: &HttpClientFactory, + site_hostname: &str, + config: Option<&CaptchaConfig>, + ) -> Result<(), Error> { + let Some(config) = config else { + if self.g_recaptcha_response.is_some() + || self.h_captcha_response.is_some() + || self.cf_turnstile_response.is_some() + { + return Err(Error::NoCaptchaConfigured); + } + + return Ok(()); + }; + + let remoteip = activity_tracker.ip(); + let secret = &config.secret_key; + + let span = tracing::Span::current(); + span.record("captcha.service", tracing::field::debug(config.service)); + + let request = match ( + config.service, + &self.g_recaptcha_response, + &self.h_captcha_response, + &self.cf_turnstile_response, + ) { + (_, None, None, None) => return Err(Error::MissingCaptchaResponse), + + // reCAPTCHA v2 + (CaptchaService::RecaptchaV2, Some(response), None, None) => { + Request::post(RECAPTCHA_VERIFY_URL) + .body(VerificationRequest { + secret, + response, + remoteip, + }) + .unwrap() + } + + // hCaptcha + // XXX: this is also allowing g-recaptcha-response, because apparently hCaptcha thought + // it was a good idea to send both + (CaptchaService::HCaptcha, _, Some(response), None) => { + Request::post(HCAPTCHA_VERIFY_URL) + .body(VerificationRequest { + secret, + response, + remoteip, + }) + .unwrap() + } + + // Cloudflare Turnstile + (CaptchaService::CloudflareTurnstile, None, None, Some(response)) => { + Request::post(CF_TURNSTILE_VERIFY_URL) + .body(VerificationRequest { + secret, + response, + remoteip, + }) + .unwrap() + } + + _ => return Err(Error::CaptchaResponseMismatch), + }; + + let client = http_client_factory + .client("captcha") + .request_bytes_to_body() + .form_urlencoded_request() + .response_body_to_bytes() + .json_response::() + .map_err(|e| Error::RequestFailed(e.into())); + + let response = client.ready_oneshot().await?.call(request).await?; + let response = response.into_body(); + + if !response.success { + return Err(Error::InvalidCaptcha( + response.error_codes.unwrap_or_default(), + )); + } + + // If the response is successful, we should have both the hostname and the + // challenge_ts + let Some(hostname) = response.hostname else { + return Err(Error::InvalidResponse); + }; + + let Some(challenge_ts) = response.challenge_ts else { + return Err(Error::InvalidResponse); + }; + + span.record("captcha.hostname", &hostname); + span.record("captcha.challenge_ts", &challenge_ts); + + if hostname != site_hostname { + return Err(Error::HostnameMismatch { + expected: site_hostname.to_owned(), + got: hostname, + }); + } + + Ok(()) + } +} diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 84d28392..cc8ccd3d 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -63,6 +63,7 @@ pub mod upstream_oauth2; mod views; mod activity_tracker; +mod captcha; mod preferred_language; #[cfg(test)] mod test_utils; diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 002d8e7d..685b88dc 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -24,6 +24,7 @@ use lettre::Address; use mas_axum_utils::{ cookies::CookieJar, csrf::{CsrfExt, CsrfToken, ProtectedForm}, + http_client_factory::HttpClientFactory, FancyError, SessionInfoExt, }; use mas_data_model::{CaptchaConfig, UserAgent}; @@ -44,7 +45,10 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use super::shared::OptionalPostAuthAction; -use crate::{passwords::PasswordManager, BoundActivityTracker, PreferredLanguage, SiteConfig}; +use crate::{ + captcha::Form as CaptchaForm, passwords::PasswordManager, BoundActivityTracker, + PreferredLanguage, SiteConfig, +}; #[derive(Debug, Deserialize, Serialize)] pub(crate) struct RegisterForm { @@ -54,6 +58,9 @@ pub(crate) struct RegisterForm { password_confirm: String, #[serde(default)] accept_terms: String, + + #[serde(flatten, skip_serializing)] + captcha: CaptchaForm, } impl ToFormState for RegisterForm { @@ -114,6 +121,7 @@ pub(crate) async fn post( State(url_builder): State, State(site_config): State, State(homeserver): State, + State(http_client_factory): State, mut policy: Policy, mut repo: BoxRepository, activity_tracker: BoundActivityTracker, @@ -131,6 +139,17 @@ pub(crate) async fn post( let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + // Validate the captcha + // TODO: display a nice error message to the user + form.captcha + .verify( + &activity_tracker, + &http_client_factory, + url_builder.public_hostname(), + site_config.captcha.as_ref(), + ) + .await?; + // Validate the form let state = { let mut state = form.to_form_state(); diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index a4c3020c..a0a3daaf 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -112,6 +112,18 @@ impl UrlBuilder { } } + /// Site public hostname + /// + /// # Panics + /// + /// Panics if the base URL does not have a host + #[must_use] + pub fn public_hostname(&self) -> &str { + self.http_base + .host_str() + .expect("base URL must have a host") + } + /// HTTP base #[must_use] pub fn http_base(&self) -> Url {