From 53d51cf83108221b0d4a6003f39674d7e345a2b9 Mon Sep 17 00:00:00 2001 From: Ginger Date: Wed, 6 May 2026 11:03:44 -0400 Subject: [PATCH] refactor: Change template context to allow using a CSP nonce --- src/api/client/account/mod.rs | 2 +- src/api/client/mod.rs | 2 +- src/service/uiaa/mod.rs | 257 +++++++++--------- src/web/mod.rs | 11 +- src/web/pages/account/cross_signing_reset.rs | 9 +- src/web/pages/account/deactivate.rs | 16 +- src/web/pages/account/device.rs | 12 +- src/web/pages/account/email.rs | 44 +-- src/web/pages/account/login.rs | 12 +- src/web/pages/account/mod.rs | 12 +- src/web/pages/account/password/change.rs | 22 +- src/web/pages/account/password/reset.rs | 35 ++- src/web/pages/components/form.rs | 18 +- src/web/pages/index.rs | 11 +- src/web/pages/mod.rs | 54 +++- src/web/pages/oauth/grant.rs | 7 +- .../pages/templates/_components/form.html.j2 | 2 +- .../templates/cross_signing_reset.html.j2 | 2 +- src/web/pages/threepid.rs | 7 +- 19 files changed, 304 insertions(+), 231 deletions(-) diff --git a/src/api/client/account/mod.rs b/src/api/client/account/mod.rs index a30093356..8ecaa9ffd 100644 --- a/src/api/client/account/mod.rs +++ b/src/api/client/account/mod.rs @@ -24,7 +24,7 @@ use ruma::{ power_levels::RoomPowerLevelsEventContent, }, }; -use service::{mailer::messages, users::HashedPassword, uiaa::UiaaInitiator}; +use service::{mailer::messages, uiaa::UiaaInitiator, users::HashedPassword}; use super::{DEVICE_ID_LENGTH, TOKEN_LENGTH}; use crate::{Ruma, router::ClientIdentity}; diff --git a/src/api/client/mod.rs b/src/api/client/mod.rs index 60bab3111..32438a88c 100644 --- a/src/api/client/mod.rs +++ b/src/api/client/mod.rs @@ -75,8 +75,8 @@ pub(super) use report::*; pub(super) use room::*; pub(super) use search::*; pub(super) use send::*; -pub(super) use session::*; pub use session::handle_login; +pub(super) use session::*; pub(super) use space::*; pub(super) use state::*; pub(super) use sync::*; diff --git a/src/service/uiaa/mod.rs b/src/service/uiaa/mod.rs index 0ffce1f80..184d7d90f 100644 --- a/src/service/uiaa/mod.rs +++ b/src/service/uiaa/mod.rs @@ -452,147 +452,144 @@ impl Service { )) } }, - | UiaaSessionMetadata::Legacy { identity } => { - match auth { - | AuthData::Dummy(_) => Ok(AuthType::Dummy), - | AuthData::EmailIdentity(EmailIdentity { - thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. }, - .. - }) => { - match self - .services - .threepid - .get_valid_session(sid, client_secret) - .await - { - | Ok(session) => { - let email = session.consume(); + | UiaaSessionMetadata::Legacy { identity } => match auth { + | AuthData::Dummy(_) => Ok(AuthType::Dummy), + | AuthData::EmailIdentity(EmailIdentity { + thirdparty_id_creds: ThirdpartyIdCredentials { client_secret, sid, .. }, + .. + }) => { + match self + .services + .threepid + .get_valid_session(sid, client_secret) + .await + { + | Ok(session) => { + let email = session.consume(); - if let Some(localpart) = - self.services.threepid.get_localpart_for_email(&email).await - { - identity.try_set_localpart(localpart)?; - } + if let Some(localpart) = + self.services.threepid.get_localpart_for_email(&email).await + { + identity.try_set_localpart(localpart)?; + } + identity.try_set_email(email)?; + + Ok(AuthType::EmailIdentity) + }, + | Err(message) => Err(StandardErrorBody::new( + ErrorKind::ThreepidAuthFailed, + message.into_owned(), + )), + } + }, + #[allow(clippy::useless_let_if_seq)] + | AuthData::Password(Password { identifier, password, .. }) => { + let user_id_or_localpart = match identifier { + | UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) => + user.to_owned(), + | UserIdentifier::Email(EmailUserIdentifier { address, .. }) => { + let Ok(email) = Address::try_from(address.to_owned()) else { + return Err(StandardErrorBody::new( + ErrorKind::InvalidParam, + "Email is malformed".to_owned(), + )); + }; + + if let Some(localpart) = + self.services.threepid.get_localpart_for_email(&email).await + { identity.try_set_email(email)?; - Ok(AuthType::EmailIdentity) - }, - | Err(message) => Err(StandardErrorBody::new( - ErrorKind::ThreepidAuthFailed, - message.into_owned(), - )), - } - }, - #[allow(clippy::useless_let_if_seq)] - | AuthData::Password(Password { identifier, password, .. }) => { - let user_id_or_localpart = match identifier { - | UserIdentifier::Matrix(MatrixUserIdentifier { user, .. }) => - user.to_owned(), - | UserIdentifier::Email(EmailUserIdentifier { address, .. }) => { - let Ok(email) = Address::try_from(address.to_owned()) else { - return Err(StandardErrorBody::new( - ErrorKind::InvalidParam, - "Email is malformed".to_owned(), - )); - }; - - if let Some(localpart) = - self.services.threepid.get_localpart_for_email(&email).await - { - identity.try_set_email(email)?; - - localpart - } else { - return Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "Invalid identifier or password".to_owned(), - )); - } - }, - | _ => + localpart + } else { return Err(StandardErrorBody::new( - ErrorKind::Unrecognized, - "Identifier type not recognized".to_owned(), - )), - }; - - let Ok(user_id) = UserId::parse_with_server_name( - user_id_or_localpart, - self.services.globals.server_name(), - ) else { - return Err(StandardErrorBody::new( - ErrorKind::InvalidParam, - "User ID is malformed".to_owned(), - )); - }; - - if self - .services - .users - .check_password(&user_id, password) - .await - .is_ok() - { - identity.try_set_localpart(user_id.localpart().to_owned())?; - - Ok(AuthType::Password) - } else { - Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "Invalid identifier or password".to_owned(), - )) - } - }, - | AuthData::ReCaptcha(ReCaptcha { response, .. }) => { - let Some(ref private_site_key) = - self.services.config.recaptcha_private_site_key - else { - return Err(StandardErrorBody::new( - ErrorKind::Forbidden, - "ReCaptcha is not configured".to_owned(), - )); - }; - - match recaptcha_verify::verify_v3(private_site_key, response, None).await - { - | Ok(()) => Ok(AuthType::ReCaptcha), - | Err(e) => { - error!("ReCaptcha verification failed: {e:?}"); - Err(StandardErrorBody::new( ErrorKind::Forbidden, - "ReCaptcha verification failed".to_owned(), - )) - }, - } - }, - | AuthData::RegistrationToken(RegistrationToken { token, .. }) => { - let token = token.trim().to_owned(); + "Invalid identifier or password".to_owned(), + )); + } + }, + | _ => + return Err(StandardErrorBody::new( + ErrorKind::Unrecognized, + "Identifier type not recognized".to_owned(), + )), + }; - if let Some(valid_token) = self - .services - .registration_tokens - .validate_token(token) - .await - { - self.services - .registration_tokens - .mark_token_as_used(valid_token); + let Ok(user_id) = UserId::parse_with_server_name( + user_id_or_localpart, + self.services.globals.server_name(), + ) else { + return Err(StandardErrorBody::new( + ErrorKind::InvalidParam, + "User ID is malformed".to_owned(), + )); + }; - Ok(AuthType::RegistrationToken) - } else { + if self + .services + .users + .check_password(&user_id, password) + .await + .is_ok() + { + identity.try_set_localpart(user_id.localpart().to_owned())?; + + Ok(AuthType::Password) + } else { + Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "Invalid identifier or password".to_owned(), + )) + } + }, + | AuthData::ReCaptcha(ReCaptcha { response, .. }) => { + let Some(ref private_site_key) = + self.services.config.recaptcha_private_site_key + else { + return Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "ReCaptcha is not configured".to_owned(), + )); + }; + + match recaptcha_verify::verify_v3(private_site_key, response, None).await { + | Ok(()) => Ok(AuthType::ReCaptcha), + | Err(e) => { + error!("ReCaptcha verification failed: {e:?}"); Err(StandardErrorBody::new( ErrorKind::Forbidden, - "Invalid registration token".to_owned(), + "ReCaptcha verification failed".to_owned(), )) - } - }, - | AuthData::Terms(_) => Ok(AuthType::Terms), - | _ => Err(StandardErrorBody::new( - ErrorKind::Unrecognized, - "Unsupported stage type".into(), - )), - } + }, + } + }, + | AuthData::RegistrationToken(RegistrationToken { token, .. }) => { + let token = token.trim().to_owned(); + + if let Some(valid_token) = self + .services + .registration_tokens + .validate_token(token) + .await + { + self.services + .registration_tokens + .mark_token_as_used(valid_token); + + Ok(AuthType::RegistrationToken) + } else { + Err(StandardErrorBody::new( + ErrorKind::Forbidden, + "Invalid registration token".to_owned(), + )) + } + }, + | AuthData::Terms(_) => Ok(AuthType::Terms), + | _ => Err(StandardErrorBody::new( + ErrorKind::Unrecognized, + "Unsupported stage type".into(), + )), }, }?; diff --git a/src/web/mod.rs b/src/web/mod.rs index b4cdbf6ae..64225d0d7 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -4,11 +4,12 @@ use askama::Template; use axum::{ Router, extract::rejection::{FormRejection, PathRejection, QueryRejection}, - http::{HeaderValue, StatusCode, header}, + http::StatusCode, + middleware::from_fn_with_state, response::{Html, IntoResponse, Redirect, Response}, }; use conduwuit_service::{Services, state}; -use tower_http::{catch_panic::CatchPanicLayer, set_header::SetResponseHeaderLayer}; +use tower_http::catch_panic::CatchPanicLayer; use tower_sec_fetch::SecFetchLayer; use tower_sessions::{ExpiredDeletion, SessionManagerLayer, cookie::SameSite}; @@ -95,6 +96,7 @@ impl IntoResponse for WebError { context: TemplateContext { // Statically set false to prevent error pages from being indexed. allow_indexing: false, + csp_nonce: String::new(), }, }; @@ -150,10 +152,7 @@ pub fn build(services: &Services) -> Router { WebError::Panic(details).into_response() })) - .layer(SetResponseHeaderLayer::if_not_present( - header::CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'self'; img-src 'self' https: data:;"), - )) + .layer(from_fn_with_state(services.config.clone(), template_context_middleware)) .layer(SecFetchLayer::new(|policy| { policy.allow_safe_methods().reject_missing_metadata(); })) diff --git a/src/web/pages/account/cross_signing_reset.rs b/src/web/pages/account/cross_signing_reset.rs index b6391b0e8..412dfc768 100644 --- a/src/web/pages/account/cross_signing_reset.rs +++ b/src/web/pages/account/cross_signing_reset.rs @@ -1,9 +1,9 @@ -use axum::{Router, extract::State, routing::on}; +use axum::{Extension, Router, extract::State, routing::on}; use conduwuit_service::oauth::OAuthTicket; use crate::{ extract::PostForm, - pages::{GET_POST, Result, components::UserCard}, + pages::{GET_POST, Result, TemplateContext, components::UserCard}, response, session::{LoginTarget, User}, template, @@ -28,6 +28,7 @@ enum CrossSigningResetBody { async fn route_cross_signing_reset( State(services): State, + Extension(context): Extension, user: User, PostForm(form): PostForm<()>, ) -> Result { @@ -39,8 +40,8 @@ async fn route_cross_signing_reset( .oauth .issue_ticket(user_id.localpart().to_owned(), OAuthTicket::CrossSigningReset); - response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Success)) + response!(CrossSigningReset::new(context, user_card, CrossSigningResetBody::Success)) } else { - response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Form)) + response!(CrossSigningReset::new(context, user_card, CrossSigningResetBody::Form)) } } diff --git a/src/web/pages/account/deactivate.rs b/src/web/pages/account/deactivate.rs index 9b2da73d8..01e6279f7 100644 --- a/src/web/pages/account/deactivate.rs +++ b/src/web/pages/account/deactivate.rs @@ -1,4 +1,4 @@ -use axum::{Router, extract::State, routing::on}; +use axum::{Extension, Router, extract::State, routing::on}; use conduwuit_api::client::full_user_deactivate; use futures::StreamExt; use ruma::{OwnedRoomId, OwnedUserId, UserId}; @@ -9,7 +9,7 @@ use crate::{ extract::PostForm, form, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, components::{UserCard, form::Form}, }, response, @@ -28,6 +28,7 @@ template! { } #[derive(Debug)] +#[allow(clippy::large_enum_variant)] enum DeactivateBody { Form { user_id: OwnedUserId, @@ -57,6 +58,7 @@ form! { async fn route_deactivate( State(services): State, + Extension(context): Extension, user: User, session: Session, PostForm(form): PostForm, @@ -70,7 +72,7 @@ async fn route_deactivate( DeactivateBody::Form { user_id, user_card, - form: DeactivateForm::with_errors(err), + form: DeactivateForm::with_errors(context.clone(), err), } } else { let all_joined_rooms: Vec = services @@ -90,12 +92,12 @@ async fn route_deactivate( DeactivateBody::Form { user_id, user_card, - form: DeactivateForm::build(), + form: DeactivateForm::build(context.clone()), } } }; - response!(Deactivate::new(&services, body)) + response!(Deactivate::new(context, body)) } async fn validate_deactivate_form( @@ -105,7 +107,9 @@ async fn validate_deactivate_form( ) -> Result<(), ValidationErrors> { form.validate()?; - if services.users.check_password(user_id, &form.password) + if services + .users + .check_password(user_id, &form.password) .await .is_err() { diff --git a/src/web/pages/account/device.rs b/src/web/pages/account/device.rs index 49f271786..05d602664 100644 --- a/src/web/pages/account/device.rs +++ b/src/web/pages/account/device.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::{Path, State}, routing::{get, on}, }; @@ -12,7 +12,7 @@ use crate::{ WebError, extract::{Expect, PostForm}, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, components::{ClientScopes, DeviceCard, DeviceCardStyle}, }, response, @@ -35,6 +35,7 @@ template! { async fn get_device_info( State(services): State, + Extension(context): Extension, user: User, Expect(Path(query)): Expect>, ) -> Result { @@ -65,7 +66,7 @@ async fn get_device_info( let device_card = DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Detailed).await; - response!(DeviceInfo::new(&services, device_card, client_metadata)) + response!(DeviceInfo::new(context, device_card, client_metadata)) } template! { @@ -90,6 +91,7 @@ pub(crate) struct DevicePath { async fn route_remove_device( State(services): State, + Extension(context): Extension, user: User, Expect(Path(query)): Expect>, PostForm(form): PostForm<()>, @@ -110,13 +112,13 @@ async fn route_remove_device( .remove_device(&user_id, &device.device_id) .await; - response!(RemoveDevice::new(&services, RemoveDeviceBody::Success)) + response!(RemoveDevice::new(context, RemoveDeviceBody::Success)) } else { let device_card = DeviceCard::for_device(&services, &user_id, device, DeviceCardStyle::Minimal).await; let last_device = services.users.all_devices_metadata(&user_id).count().await <= 1; - response!(RemoveDevice::new(&services, RemoveDeviceBody::Form { + response!(RemoveDevice::new(context, RemoveDeviceBody::Form { device_card: Box::new(device_card), last_device })) diff --git a/src/web/pages/account/email.rs b/src/web/pages/account/email.rs index 7dbbc634e..b68564e70 100644 --- a/src/web/pages/account/email.rs +++ b/src/web/pages/account/email.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::{Query, State}, routing::{get, on, post}, }; @@ -14,7 +14,7 @@ use crate::{ extract::{Expect, PostForm}, form, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, account::ThreepidQuery, components::{UserCard, form::Form}, }, @@ -75,25 +75,24 @@ enum ChangeEmailBody { async fn route_change_email_request( State(services): State, + Extension(context): Extension, user: User, PostForm(form): PostForm, ) -> Result { let user_id = user.expect_recent(LoginTarget::ChangeEmail)?; - let template = ChangeEmailRequest::new( - &services, - UserCard::for_local_user(&services, user_id.clone()).await, - services - .threepid - .get_email_for_localpart(user_id.localpart()) - .await - .map(|address| address.to_string()), - ChangeEmailRequestForm::build(), - services.threepid.email_requirement().may_remove(), - ); - let Some(form) = form else { - return response!(template); + return response!(ChangeEmailRequest::new( + context.clone(), + UserCard::for_local_user(&services, user_id.clone()).await, + services + .threepid + .get_email_for_localpart(user_id.localpart()) + .await + .map(|address| address.to_string()), + ChangeEmailRequestForm::build(context), + services.threepid.email_requirement().may_remove(), + )); }; let client_secret = ClientSecret::new(); @@ -130,7 +129,7 @@ async fn route_change_email_request( }; response!(ChangeEmail::new( - &services, + context, UserCard::for_local_user(&services, user_id).await, ChangeEmailBody::ValidationPending { session_id, @@ -148,6 +147,7 @@ struct ChangeEmailQuery { async fn get_change_email( State(services): State, + Extension(context): Extension, Expect(Query(ChangeEmailQuery { threepid: ThreepidQuery { client_secret, session_id }, })): Expect>, @@ -166,7 +166,7 @@ async fn get_change_email( .await else { return response!(ChangeEmail::new( - &services, + context, user_card, ChangeEmailBody::ValidationPending { session_id, @@ -186,10 +186,14 @@ async fn get_change_email( return response!(BadRequest(err.message())); } - response!(ChangeEmail::new(&services, user_card, ChangeEmailBody::Success)) + response!(ChangeEmail::new(context, user_card, ChangeEmailBody::Success)) } -async fn post_delete_email(State(services): State, user: User) -> Result { +async fn post_delete_email( + State(services): State, + Extension(context): Extension, + user: User, +) -> Result { let user_id = user.expect(LoginTarget::ChangeEmail)?; let user_card = UserCard::for_local_user(&services, user_id.clone()).await; @@ -202,5 +206,5 @@ async fn post_delete_email(State(services): State, user: User) -> .disassociate_localpart_email(user_id.localpart()) .await; - response!(DeleteEmail::new(&services, user_card)) + response!(DeleteEmail::new(context, user_card)) } diff --git a/src/web/pages/account/login.rs b/src/web/pages/account/login.rs index 918252504..d3e50bbd5 100644 --- a/src/web/pages/account/login.rs +++ b/src/web/pages/account/login.rs @@ -1,7 +1,7 @@ use std::time::SystemTime; use axum::{ - Router, + Extension, Router, extract::{Query, RawQuery, State}, response::{IntoResponse, Redirect}, routing::{get, on}, @@ -17,7 +17,7 @@ use tower_sessions::Session; use crate::{ ROUTE_PREFIX, WebError, extract::{Expect, PostForm}, - pages::{GET_POST, Result, components::UserCard}, + pages::{GET_POST, Result, TemplateContext, components::UserCard}, response, session::{LoginQuery, LoginTarget, User, UserSession}, template, @@ -55,6 +55,7 @@ struct LoginForm { async fn route_login( State(services): State, + Extension(context): Extension, Expect(Query(LoginQuery { next, reauthenticate })): Expect>, session_store: Session, user: User, @@ -78,13 +79,16 @@ async fn route_login( }, }; - let mut template = Login::new(&services, body, next != LoginTarget::Account, None); + let mut template = Login::new(context, body, next != LoginTarget::Account, None); if let Some(form) = form { let login_result = match (user_id, form.identifier) { | (Some(user_id), _) => { // The user is already authenticated, we need to check their password - services.users.check_password(&user_id, &form.password).await + services + .users + .check_password(&user_id, &form.password) + .await }, | (None, Some(identifier)) => { // The user isn't authenticated, we need to log them in diff --git a/src/web/pages/account/mod.rs b/src/web/pages/account/mod.rs index 781893a1d..10dccb6d1 100644 --- a/src/web/pages/account/mod.rs +++ b/src/web/pages/account/mod.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::{Query, State}, response::Redirect, routing::get, @@ -17,7 +17,7 @@ use crate::{ WebError, extract::Expect, pages::{ - Result, + Result, TemplateContext, components::{DeviceCard, DeviceCardStyle, UserCard}, }, response, @@ -62,7 +62,11 @@ template! { } } -async fn get_account(State(services): State, user: User) -> Result { +async fn get_account( + State(services): State, + Extension(context): Extension, + user: User, +) -> Result { let user_id = user.expect(LoginTarget::Account)?; let email_requirement = services.threepid.email_requirement(); @@ -105,7 +109,7 @@ async fn get_account(State(services): State, user: User) -> Result .collect() .await; - response!(Account::new(&services, user_card, email_requirement, email, device_cards)) + response!(Account::new(context, user_card, email_requirement, email, device_cards)) } #[derive(Deserialize)] diff --git a/src/web/pages/account/password/change.rs b/src/web/pages/account/password/change.rs index b54aa578b..f484b72c5 100644 --- a/src/web/pages/account/password/change.rs +++ b/src/web/pages/account/password/change.rs @@ -1,4 +1,4 @@ -use axum::{Router, extract::State, routing::on}; +use axum::{Extension, Router, extract::State, routing::on}; use conduwuit_service::users::HashedPassword; use ruma::UserId; use validator::{Validate, ValidationError, ValidationErrors}; @@ -7,7 +7,7 @@ use crate::{ extract::PostForm, form, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, components::{UserCard, form::Form}, }, response, @@ -61,6 +61,7 @@ form! { async fn route_change_password( State(services): State, + Extension(context): Extension, user: User, PostForm(form): PostForm, ) -> Result { @@ -70,13 +71,14 @@ async fn route_change_password( let body = if let Some(form) = form { match change_password(&services, &user_id, form).await { | Ok(()) => ChangePasswordBody::Success, - | Err(errors) => ChangePasswordBody::Form(ChangePasswordForm::with_errors(errors)), + | Err(errors) => + ChangePasswordBody::Form(ChangePasswordForm::with_errors(context.clone(), errors)), } } else { - ChangePasswordBody::Form(ChangePasswordForm::build()) + ChangePasswordBody::Form(ChangePasswordForm::build(context.clone())) }; - response!(ChangePassword::new(&services, user_card, body)) + response!(ChangePassword::new(context, user_card, body)) } async fn change_password( @@ -86,7 +88,9 @@ async fn change_password( ) -> Result<(), ValidationErrors> { form.validate()?; - if services.users.check_password(user_id, &form.current_password) + if services + .users + .check_password(user_id, &form.current_password) .await .is_err() { @@ -100,10 +104,10 @@ async fn change_password( } match HashedPassword::new(&form.new_password) { - Ok(hash) => { + | Ok(hash) => { services.users.set_password(user_id, Some(hash)); }, - Err(err) => { + | Err(err) => { let mut errors = ValidationErrors::new(); errors.add( "new_password", @@ -111,7 +115,7 @@ async fn change_password( ); return Err(errors); - } + }, } Ok(()) diff --git a/src/web/pages/account/password/reset.rs b/src/web/pages/account/password/reset.rs index 6fca70dce..0cd6b5ffe 100644 --- a/src/web/pages/account/password/reset.rs +++ b/src/web/pages/account/password/reset.rs @@ -1,10 +1,12 @@ use axum::{ - Router, + Extension, Router, extract::{Query, State}, routing::on, }; use conduwuit_core::warn; -use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions, users::HashedPassword}; +use conduwuit_service::{ + mailer::messages, threepid::session::ValidationSessions, users::HashedPassword, +}; use lettre::{Address, message::Mailbox}; use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, UserId}; use serde::{Deserialize, Serialize}; @@ -15,7 +17,7 @@ use crate::{ extract::{Expect, PostForm}, form, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, account::ThreepidQuery, components::{UserCard, form::Form}, }, @@ -55,12 +57,13 @@ form! { async fn route_reset_password_request( State(services): State, + Extension(context): Extension, PostForm(form): PostForm, ) -> Result { // Check if SMTP is configured if services.mailer.mailer().is_none() { return response!(ResetPasswordRequest::new( - &services, + context, ResetPasswordRequestBody::Unavailable )); } @@ -68,8 +71,8 @@ async fn route_reset_password_request( let Some(form) = form else { // For GET requests return the reset request form return response!(ResetPasswordRequest::new( - &services, - ResetPasswordRequestBody::Form(ResetPasswordRequestForm::build()) + context.clone(), + ResetPasswordRequestBody::Form(ResetPasswordRequestForm::build(context)) )); }; @@ -115,7 +118,7 @@ async fn route_reset_password_request( ValidationSessions::generate_session_id() }); - response!(ResetPassword::new(&services, ResetPasswordBody::ValidationPending { + response!(ResetPassword::new(context, ResetPasswordBody::ValidationPending { client_secret, session_id, validation_error: false @@ -172,6 +175,7 @@ struct ResetPasswordQuery { async fn route_reset_password( State(services): State, + Extension(context): Extension, Expect(Query(query)): Expect>, PostForm(form): PostForm, ) -> Result { @@ -201,36 +205,37 @@ async fn route_reset_password( if let Err(err) = form.validate() { ResetPasswordBody::ValidationSuccess { user_card, - form: ResetPasswordForm::with_errors(err), + form: ResetPasswordForm::with_errors(context.clone(), err), } } else { match HashedPassword::new(&form.new_password) { - Ok(hash) => { + | Ok(hash) => { let _ = session.consume(); services.users.set_password(&user_id, Some(hash)); ResetPasswordBody::ResetSuccess { user_card } }, - Err(err) => { + | Err(err) => { let mut errors = ValidationErrors::new(); errors.add( "new_password", - ValidationError::new("malformed").with_message(err.message().into()), + ValidationError::new("malformed") + .with_message(err.message().into()), ); ResetPasswordBody::ValidationSuccess { user_card, - form: ResetPasswordForm::with_errors(errors), + form: ResetPasswordForm::with_errors(context.clone(), errors), } - } + }, } } } else { ResetPasswordBody::ValidationSuccess { user_card, - form: ResetPasswordForm::build(), + form: ResetPasswordForm::build(context.clone()), } } }, @@ -241,5 +246,5 @@ async fn route_reset_password( }, }; - response!(ResetPassword::new(&services, body)) + response!(ResetPassword::new(context, body)) } diff --git a/src/web/pages/components/form.rs b/src/web/pages/components/form.rs index 88f915d63..55b07bda3 100644 --- a/src/web/pages/components/form.rs +++ b/src/web/pages/components/form.rs @@ -1,10 +1,13 @@ use askama::{Template, filters::HtmlSafe}; use validator::ValidationErrors; +use crate::pages::TemplateContext; + /// A reusable form component with field validation. #[derive(Debug, Template)] #[template(path = "_components/form.html.j2")] pub(crate) struct Form<'a> { + context: TemplateContext, inputs: Vec>, submit_label: &'a str, slowdown: bool, @@ -12,8 +15,14 @@ pub(crate) struct Form<'a> { } impl<'a> Form<'a> { - pub(crate) fn new(inputs: Vec>, submit_label: &'a str, slowdown: bool) -> Self { + pub(crate) fn new( + context: TemplateContext, + inputs: Vec>, + submit_label: &'a str, + slowdown: bool, + ) -> Self { Self { + context, inputs, submit_label, slowdown, @@ -100,8 +109,9 @@ macro_rules! form { impl $struct_name { /// Generate a [`Form`] which matches the shape of this struct. #[allow(clippy::needless_update)] - fn build() -> $crate::pages::components::form::Form<'static> { + fn build(context: TemplateContext) -> $crate::pages::components::form::Form<'static> { $crate::pages::components::form::Form::new( + context, vec![ $( $crate::pages::components::form::FormInput { @@ -119,8 +129,8 @@ macro_rules! form { /// Generate a [`Form`] with validation errors. #[allow(unused)] - fn with_errors(errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> { - let mut form = Self::build(); + fn with_errors(context: TemplateContext, errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> { + let mut form = Self::build(context); form.validation_errors = Some(errors); form } diff --git a/src/web/pages/index.rs b/src/web/pages/index.rs index 17595c3db..7a7808cf2 100644 --- a/src/web/pages/index.rs +++ b/src/web/pages/index.rs @@ -1,6 +1,6 @@ -use axum::{Router, extract::State, response::IntoResponse, routing::get}; +use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get}; -use crate::{WebError, template}; +use crate::{WebError, pages::TemplateContext, template}; pub(crate) fn build() -> Router { Router::new() @@ -8,7 +8,10 @@ pub(crate) fn build() -> Router { .route(&format!("{}/", crate::ROUTE_PREFIX), get(index)) } -async fn index(State(services): State) -> Result { +async fn index( + State(services): State, + Extension(context): Extension, +) -> Result { template! { struct Index<'a> use "index.html.j2" { server_name: &'a str, @@ -17,7 +20,7 @@ async fn index(State(services): State) -> Result = std::result::Result; const GET_POST: MethodFilter = MethodFilter::GET.or(MethodFilter::POST); -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct TemplateContext { pub allow_indexing: bool, + pub csp_nonce: String, } -impl From<&crate::State> for TemplateContext { - fn from(state: &crate::State) -> Self { - Self { - allow_indexing: state.config.allow_web_indexing, - } - } +const CSP_NONCE_LENGTH: usize = 32; + +pub(super) async fn template_context_middleware( + State(config): State>, + mut request: Request, + next: Next, +) -> Response { + let csp_nonce = utils::random_string(CSP_NONCE_LENGTH); + let context = TemplateContext { + allow_indexing: config.allow_web_indexing, + csp_nonce: csp_nonce.clone(), + }; + + assert!( + request.extensions_mut().insert(context).is_none(), + "template context should only be inserted once" + ); + + let mut response = next.run(request).await; + + response.headers_mut().insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_str(&format!( + "default-src 'none'; style-src 'self'; img-src 'self' 'https' data:; script-src \ + 'nonce-{csp_nonce}';" + )) + .expect("should be able to build CSP header"), + ); + + response } #[macro_export] @@ -43,9 +77,9 @@ macro_rules! template { impl$(<$lifetime>)? $name$(<$lifetime>)? { #[allow(clippy::too_many_arguments)] - fn new(state: &$crate::State, $($field_name: $field_type,)*) -> Self { + fn new(context: $crate::pages::TemplateContext, $($field_name: $field_type,)*) -> Self { Self { - context: state.into(), + context, $($field_name,)* } } diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index 070bcd118..15f0e6b91 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::{Query, State}, response::Redirect, routing::on, @@ -12,7 +12,7 @@ use crate::{ WebError, extract::{Expect, PostForm}, pages::{ - GET_POST, Result, + GET_POST, Result, TemplateContext, components::{Avatar, AvatarType, ClientScopes}, }, response, @@ -40,6 +40,7 @@ template! { async fn route_authorization_code( State(services): State, + Extension(context): Extension, user: User, Expect(Query(query)): Expect>, PostForm(form): PostForm<()>, @@ -86,7 +87,7 @@ async fn route_authorization_code( let user_avatar = Avatar::for_local_user(&services, &user_id).await; response!(Grant::new( - &services, + context, serde_urlencoded::to_string(LoginQuery { next: Some(LoginTarget::AuthorizationCode(query)), reauthenticate: false, diff --git a/src/web/pages/templates/_components/form.html.j2 b/src/web/pages/templates/_components/form.html.j2 index 7c50fcb47..646632c0d 100644 --- a/src/web/pages/templates/_components/form.html.j2 +++ b/src/web/pages/templates/_components/form.html.j2 @@ -45,6 +45,6 @@ {% if slowdown %} - + {% endif %} diff --git a/src/web/pages/templates/cross_signing_reset.html.j2 b/src/web/pages/templates/cross_signing_reset.html.j2 index 3e1e5f829..bcf8a613d 100644 --- a/src/web/pages/templates/cross_signing_reset.html.j2 +++ b/src/web/pages/templates/cross_signing_reset.html.j2 @@ -30,7 +30,7 @@ Reset your digital identity
- + {% when CrossSigningResetBody::Success %}

The identity reset has been approved for the next ten minutes. diff --git a/src/web/pages/threepid.rs b/src/web/pages/threepid.rs index b660f11a7..5aefcf6f0 100644 --- a/src/web/pages/threepid.rs +++ b/src/web/pages/threepid.rs @@ -1,5 +1,5 @@ use axum::{ - Router, + Extension, Router, extract::{Query, State, rejection::QueryRejection}, response::IntoResponse, routing::get, @@ -7,7 +7,7 @@ use axum::{ use ruma::OwnedSessionId; use serde::Deserialize; -use crate::{WebError, template}; +use crate::{WebError, pages::TemplateContext, template}; template! { struct ThreepidValidation use "threepid_validation.html.j2" {} @@ -25,6 +25,7 @@ struct ThreepidValidationQuery { async fn threepid_validation( State(services): State, + Extension(context): Extension, query: Result, QueryRejection>, ) -> Result { let Query(query) = query?; @@ -35,5 +36,5 @@ async fn threepid_validation( .await .map_err(|message| WebError::BadRequest(message.into_owned()))?; - Ok(ThreepidValidation::new(&services)) + Ok(ThreepidValidation::new(context)) }