diff --git a/crates/cli/src/app_state.rs b/crates/cli/src/app_state.rs index 25a295d0c..4400a5ff7 100644 --- a/crates/cli/src/app_state.rs +++ b/crates/cli/src/app_state.rs @@ -1,4 +1,4 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -12,8 +12,8 @@ use ipnetwork::IpNetwork; use mas_context::LogContext; use mas_data_model::{AppVersion, BoxClock, BoxRng, SiteConfig, SystemClock}; use mas_handlers::{ - ActivityTracker, ClientIp, CookieManager, ErrorWrapper, GraphQLSchema, Limiter, MetadataCache, - passwords::PasswordManager, + ActivityTracker, ClientIp, CookieManager, Csp, ErrorWrapper, GraphQLSchema, Limiter, + MetadataCache, passwords::PasswordManager, }; use mas_i18n::Translator; use mas_keystore::{Encrypter, Keystore}; @@ -48,6 +48,7 @@ pub struct AppState { pub activity_tracker: ActivityTracker, pub trusted_proxies: Vec, pub limiter: Limiter, + pub csp: Csp, } impl AppState { @@ -197,6 +198,12 @@ impl FromRef for SiteConfig { } } +impl FromRef for Csp { + fn from_ref(input: &AppState) -> Self { + input.csp.clone() + } +} + impl FromRef for Limiter { fn from_ref(input: &AppState) -> Self { input.limiter.clone() diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 5ff5f3cf8..9fe146d6e 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -1,4 +1,4 @@ -// Copyright 2026 Element Creations Ltd. +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -235,6 +235,8 @@ impl Options { limiter.clone(), ); + let csp = mas_handlers::Csp::new(&site_config, &url_builder); + let state = { let mut s = AppState { repository_factory: PgRepositoryFactory::new(pool), @@ -253,6 +255,7 @@ impl Options { activity_tracker, trusted_proxies, limiter, + csp, }; s.init_metrics(); s.init_metadata_cache(); diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index e038bab76..b99b6ecb6 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -18,11 +18,16 @@ use axum::{ }; use camino::Utf8PathBuf; use headers::{CacheControl, HeaderMapExt as _, UserAgent}; -use hyper::{Method, Request, Response, StatusCode, Version, header::USER_AGENT}; +use hyper::{ + Method, Request, Response, StatusCode, Version, + header::{ + CONTENT_SECURITY_POLICY, HeaderValue, USER_AGENT, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS, + }, +}; use listenfd::ListenFd; use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp}; use mas_context::LogContext; -use mas_handlers::{ClientIp, GraphQLOperation}; +use mas_handlers::{ClientIp, Csp, GraphQLOperation}; use mas_listener::{ConnectionInfo, unix_or_tcp::UnixOrTcpListener}; use mas_router::Route; use mas_templates::Templates; @@ -40,7 +45,10 @@ use opentelemetry_semantic_conventions::trace::{ use rustls::ServerConfig; use sentry_tower::{NewSentryLayer, SentryHttpLayer}; use tower::Layer; -use tower_http::services::{ServeDir, fs::ServeFileSystemResponseBody}; +use tower_http::{ + services::{ServeDir, fs::ServeFileSystemResponseBody}, + set_header::SetResponseHeaderLayer, +}; use tracing::Span; use tracing_opentelemetry::OpenTelemetrySpanExt; @@ -273,6 +281,7 @@ pub fn build_router( name: Option<&str>, ) -> Router<()> { let templates = Templates::from_ref(&state); + let csp = Csp::from_ref(&state); let mut router = Router::new(); for resource in resources { @@ -286,9 +295,9 @@ pub fn build_router( mas_config::HttpResource::Discovery => { router.merge(mas_handlers::discovery_router::()) } - mas_config::HttpResource::Human => { - router.merge(mas_handlers::human_router::(templates.clone())) - } + mas_config::HttpResource::Human => router.merge( + mas_handlers::human_router::(templates.clone(), &csp), + ), mas_config::HttpResource::GraphQL { undocumented_oauth2_access, } => router.merge(mas_handlers::graphql_router::( @@ -326,11 +335,11 @@ pub fn build_router( ) } mas_config::HttpResource::OAuth => router.merge(mas_handlers::api_router::()), - mas_config::HttpResource::Compat => { - router.merge(mas_handlers::compat_router::(templates.clone())) - } + mas_config::HttpResource::Compat => router.merge( + mas_handlers::compat_router::(templates.clone(), &csp), + ), mas_config::HttpResource::AdminApi => { - let (_, api_router) = mas_handlers::admin_api_router::(); + let (_, api_router) = mas_handlers::admin_api_router::(&csp); router.merge(api_router) } // TODO: do a better handler here @@ -357,6 +366,20 @@ pub fn build_router( router = router.fallback(mas_handlers::fallback); router + // Catch-all security headers: inner routers set stricter policies on + // their own routes, and those win as this layer is `if_not_present` + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.locked_down(), + )) + .layer(SetResponseHeaderLayer::if_not_present( + X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) .layer(axum::middleware::from_fn(log_response_middleware)) .layer( InFlightCounterLayer::new("http.server.active_requests").on_request(( diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 58a9e0aa4..e30504237 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -18,7 +18,7 @@ use axum::{ http::HeaderName, response::Html, }; -use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_SECURITY_POLICY, CONTENT_TYPE}; use indexmap::IndexMap; use mas_axum_utils::InternalError; use mas_data_model::{AppVersion, BoxRng, SiteConfig}; @@ -31,7 +31,10 @@ use mas_router::{ }; use mas_templates::{ApiDocContext, Templates}; use schemars::transform::AddNullable; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::{ + cors::{Any, CorsLayer}, + set_header::SetResponseHeaderLayer, +}; mod call_context; mod model; @@ -41,7 +44,7 @@ mod schema; mod v1; use self::call_context::CallContext; -use crate::passwords::PasswordManager; +use crate::{csp::Csp, passwords::PasswordManager}; fn finish(t: TransformOpenApi) -> TransformOpenApi { t.title("Matrix Authentication Service admin API") @@ -160,7 +163,7 @@ fn oauth_security_scheme(url_builder: Option<&UrlBuilder>) -> SecurityScheme { } } -pub fn router() -> (OpenApi, Router) +pub fn router(csp: &Csp) -> (OpenApi, Router) where S: Clone + Send + Sync + 'static, Arc: FromRef, @@ -216,11 +219,18 @@ where } }), ) - // Serve the Swagger API reference - .route(ApiDoc::route(), axum::routing::get(swagger)) - .route( - ApiDocCallback::route(), - axum::routing::get(swagger_callback), + // Serve the Swagger API reference, quarantined behind its own policy + .merge( + Router::new() + .route(ApiDoc::route(), axum::routing::get(swagger)) + .route( + ApiDocCallback::route(), + axum::routing::get(swagger_callback), + ) + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.swagger(), + )), ) .layer( CorsLayer::new() @@ -255,3 +265,28 @@ async fn swagger_callback( let res = templates.render_swagger_callback(&ctx)?; Ok(Html(res)) } + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode, header::CONTENT_SECURITY_POLICY}; + use mas_router::SimpleRoute; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + /// The Swagger UI pages are quarantined behind their own policy + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let response = state + .request(Request::get(mas_router::ApiDoc::PATH).empty()) + .await; + response.assert_status(StatusCode::OK); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'", + ); + } +} diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 1b73c05c3..95eb33e13 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -63,7 +64,7 @@ impl_from_ref!(mas_data_model::SiteConfig); impl_from_ref!(mas_data_model::AppVersion); fn main() -> Result<(), Box> { - let (mut api, _) = mas_handlers::admin_api_router::(); + let (mut api, _) = mas_handlers::admin_api_router::(&mas_handlers::Csp::default()); // Set the server list to a configurable base URL api.servers = vec![Server { diff --git a/crates/handlers/src/compat/login_sso_redirect.rs b/crates/handlers/src/compat/login_sso_redirect.rs index 8edb868fd..9a62265da 100644 --- a/crates/handlers/src/compat/login_sso_redirect.rs +++ b/crates/handlers/src/compat/login_sso_redirect.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -99,11 +100,29 @@ pub async fn get( #[cfg(test)] mod tests { - use hyper::{Request, StatusCode}; + use hyper::{Request, StatusCode, header::CONTENT_SECURITY_POLICY}; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState}; + /// The compat SSO redirect is human-facing — it renders error pages through + /// `recover_error` — so it carries the human page policy + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + let state: TestState = TestState::from_pool(pool).await.unwrap(); + + let request = + Request::get("/_matrix/client/v3/login/sso/redirect?redirectUrl=http://example.com/") + .empty(); + + let response = state.request(request).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' https:; connect-src 'self'; worker-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'", + ); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_unstable_action_fallback(pool: PgPool) { let state: TestState = TestState::from_pool(pool).await.unwrap(); diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 57a938a3b..d134a430f 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -33,8 +33,8 @@ use headers::HeaderName; use hyper::{ StatusCode, Version, header::{ - ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE, - X_FRAME_OPTIONS, + ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_LENGTH, + CONTENT_SECURITY_POLICY, CONTENT_TYPE, X_FRAME_OPTIONS, }, }; use mas_axum_utils::{InternalError, cookies::CookieJar}; @@ -200,6 +200,7 @@ pub fn api_router() -> Router where S: Clone + Send + Sync + 'static, Keystore: FromRef, + MetadataCache: FromRef, UrlBuilder: FromRef, BoxRepository: FromRequestParts, ActivityTracker: FromRequestParts, @@ -219,6 +220,11 @@ where mas_router::OAuth2Keys::route(), get(self::oauth2::keys::get), ) + // Called by the upstream provider, not by a browser + .route( + mas_router::UpstreamOAuth2BackchannelLogout::route(), + post(self::upstream_oauth2::backchannel_logout::post), + ) .route( mas_router::OidcUserinfo::route(), get(self::oauth2::userinfo::get).post(self::oauth2::userinfo::get), @@ -260,7 +266,7 @@ where ) } -pub fn compat_router(templates: Templates) -> Router +pub fn compat_router(templates: Templates, csp: &Csp) -> Router where S: Clone + Send + Sync + 'static, UrlBuilder: FromRef, @@ -294,6 +300,10 @@ where async move |response: axum::response::Response| { Ok::<_, Infallible>(recover_error(&templates, response)) }, + )) + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.human(), )); // A sub-router for API-facing routes with CORS @@ -332,10 +342,11 @@ where Router::new().merge(human_router).merge(api_router) } -pub fn human_router(templates: Templates) -> Router +pub fn human_router(templates: Templates, csp: &Csp) -> Router where S: Clone + Send + Sync + 'static, UrlBuilder: FromRef, + Csp: FromRef, PreferredLanguage: FromRequestParts, BoxRepository: FromRequestParts, CookieJar: FromRequestParts, @@ -354,7 +365,38 @@ where BoxRng: FromRequestParts, Policy: FromRequestParts, { + // The password registration page is the only one which loads a captcha, so + // it is the only one which trusts the provider's origins + let register_router = Router::new() + .route( + mas_router::PasswordRegister::route(), + get(self::views::register::password::get).post(self::views::register::password::post), + ) + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.register(), + )); + + // The routes rendering the SPA shell get their own, stricter policy. The + // router-wide one below is `if_not_present`, so it yields to this one. + let app_router = Router::new() + .route(mas_router::Account::route(), get(self::views::app::get)) + .route( + mas_router::AccountWildcard::route(), + get(self::views::app::get), + ) + .route( + mas_router::AccountRecoveryFinish::route(), + get(self::views::app::get_anonymous), + ) + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.app(), + )); + Router::new() + .merge(register_router) + .merge(app_router) // XXX: hard-coded redirect from /account to /account/ .route( "/account", @@ -372,15 +414,6 @@ where }, ), ) - .route(mas_router::Account::route(), get(self::views::app::get)) - .route( - mas_router::AccountWildcard::route(), - get(self::views::app::get), - ) - .route( - mas_router::AccountRecoveryFinish::route(), - get(self::views::app::get_anonymous), - ) .route( mas_router::ChangePasswordDiscovery::route(), get(async |State(url_builder): State| { @@ -397,10 +430,6 @@ where mas_router::Register::route(), get(self::views::register::get), ) - .route( - mas_router::PasswordRegister::route(), - get(self::views::register::password::get).post(self::views::register::password::post), - ) .route( mas_router::RegisterVerifyEmail::route(), get(self::views::register::steps::verify_email::get) @@ -454,10 +483,6 @@ where mas_router::UpstreamOAuth2Link::route(), get(self::upstream_oauth2::link::get).post(self::upstream_oauth2::link::post), ) - .route( - mas_router::UpstreamOAuth2BackchannelLogout::route(), - post(self::upstream_oauth2::backchannel_logout::post), - ) .route( mas_router::DeviceCodeLink::route(), get(self::oauth2::device::link::get).post(self::oauth2::device::link::post), @@ -475,6 +500,10 @@ where X_FRAME_OPTIONS, http::HeaderValue::from_static("DENY"), )) + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + csp.human(), + )) } fn recover_error( @@ -502,6 +531,7 @@ fn recover_error( /// Returns an error if the template rendering fails. pub async fn fallback( State(templates): State, + State(csp): State, OriginalUri(uri): OriginalUri, method: Method, version: Version, @@ -512,5 +542,35 @@ pub async fn fallback( let res = templates.render_not_found(&ctx)?; - Ok((StatusCode::NOT_FOUND, Html(res))) + // This one is registered outside every router, so it only sees the + // locked-down catch-all, which would block its own stylesheet + Ok(( + StatusCode::NOT_FOUND, + [(CONTENT_SECURITY_POLICY, csp.human())], + Html(res), + )) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode, header::CONTENT_SECURITY_POLICY}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + /// The 404 page is registered outside every router, so it sets its own + /// policy rather than inheriting the locked-down catch-all, which would + /// block its stylesheet + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_fallback_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let response = state + .request(Request::get("/this-route-does-not-exist").empty()) + .await; + + response.assert_status(StatusCode::NOT_FOUND); + response.assert_header_value(CONTENT_SECURITY_POLICY, state.csp.human().to_str().unwrap()); + } } diff --git a/crates/handlers/src/oauth2/discovery.rs b/crates/handlers/src/oauth2/discovery.rs index 4530ea820..c1c08a9cc 100644 --- a/crates/handlers/src/oauth2/discovery.rs +++ b/crates/handlers/src/oauth2/discovery.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -207,7 +208,10 @@ pub(crate) async fn get( #[cfg(test)] mod tests { - use hyper::{Request, StatusCode}; + use hyper::{ + Request, StatusCode, + header::{CONTENT_SECURITY_POLICY, X_CONTENT_TYPE_OPTIONS}, + }; use mas_data_model::SiteConfig; use oauth2_types::{oidc::ProviderMetadata, requests::GrantType}; use sqlx::PgPool; @@ -259,4 +263,20 @@ mod tests { .validate(state.url_builder.oidc_issuer().as_str()) .expect("Invalid metadata"); } + + /// Machine endpoints fall back to the locked-down catch-all policy + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = Request::get("/.well-known/openid-configuration").empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + "default-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'", + ); + response.assert_header_value(X_CONTENT_TYPE_OPTIONS, "nosniff"); + } } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index b08f8086e..b2a9e89f6 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -23,7 +23,10 @@ use futures_util::future::BoxFuture; use headers::{Authorization, ContentType, HeaderMapExt, HeaderName, HeaderValue}; use hyper::{ Request, Response, StatusCode, - header::{CONTENT_TYPE, COOKIE, SET_COOKIE}, + header::{ + CONTENT_SECURITY_POLICY, CONTENT_TYPE, COOKIE, SET_COOKIE, X_CONTENT_TYPE_OPTIONS, + X_FRAME_OPTIONS, + }, }; use mas_axum_utils::{ ErrorWrapper, @@ -52,10 +55,11 @@ use tokio_util::{ task::TaskTracker, }; use tower::{Layer, Service, ServiceExt}; +use tower_http::set_header::SetResponseHeaderLayer; use url::Url; use crate::{ - ActivityTracker, ClientIp, Limiter, graphql, + ActivityTracker, ClientIp, Csp, Limiter, graphql, passwords::{Hasher, PasswordManager}, upstream_oauth2::cache::MetadataCache, }; @@ -112,6 +116,7 @@ pub(crate) struct TestState { pub site_config: SiteConfig, pub activity_tracker: ActivityTracker, pub limiter: Limiter, + pub csp: Csp, pub clock: Arc, pub rng: Arc>, pub http_client: reqwest::Client, @@ -278,6 +283,8 @@ impl TestState { let queue_worker = Arc::new(tokio::sync::Mutex::new(queue_worker)); + let csp = Csp::new(&site_config, &url_builder); + Ok(Self { repository_factory: PgRepositoryFactory::new(pool), templates, @@ -293,6 +300,7 @@ impl TestState { site_config, activity_tracker, limiter, + csp, clock, rng, http_client, @@ -350,12 +358,26 @@ impl TestState { let app = crate::healthcheck_router() .merge(crate::discovery_router()) .merge(crate::api_router()) - .merge(crate::compat_router(self.templates.clone())) - .merge(crate::human_router(self.templates.clone())) + .merge(crate::compat_router(self.templates.clone(), &self.csp)) + .merge(crate::human_router(self.templates.clone(), &self.csp)) // We enable undocumented_oauth2_access for the tests, as it is easier to query the API // with it .merge(crate::graphql_router(true)) - .merge(crate::admin_api_router().1) + .merge(crate::admin_api_router(&self.csp).1) + .fallback(crate::fallback) + // Same catch-all security headers as `mas_cli::server::build_router` + .layer(SetResponseHeaderLayer::if_not_present( + CONTENT_SECURITY_POLICY, + self.csp.locked_down(), + )) + .layer(SetResponseHeaderLayer::if_not_present( + X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + X_FRAME_OPTIONS, + HeaderValue::from_static("DENY"), + )) .with_state(self.clone()) .into_service(); @@ -634,6 +656,12 @@ impl FromRef for Arc { } } +impl FromRef for Csp { + fn from_ref(input: &TestState) -> Self { + input.csp.clone() + } +} + impl FromRef for Limiter { fn from_ref(input: &TestState) -> Self { input.limiter.clone() diff --git a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs index b5895f195..268a76f95 100644 --- a/crates/handlers/src/upstream_oauth2/backchannel_logout.rs +++ b/crates/handlers/src/upstream_oauth2/backchannel_logout.rs @@ -317,3 +317,35 @@ pub(crate) async fn post( Ok(()) } + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode, header::CONTENT_SECURITY_POLICY}; + use mas_router::Route; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + /// This is called by the upstream provider, not by a browser, so it gets + /// the locked-down policy rather than the server-rendered page one + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let path = mas_router::UpstreamOAuth2BackchannelLogout::new(Ulid::nil()) + .path_and_query() + .into_owned(); + let response = state + .request(Request::post(&path).form(serde_json::json!({}))) + .await; + + // The request is rejected, but the header does not depend on that + response.assert_status(StatusCode::BAD_REQUEST); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + state.csp.locked_down().to_str().unwrap(), + ); + } +} diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs index b5e40f02a..e3e4ee759 100644 --- a/crates/handlers/src/views/app.rs +++ b/crates/handlers/src/views/app.rs @@ -100,3 +100,33 @@ pub async fn get_anonymous( Ok(Html(content).into_response()) } + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode, header::CONTENT_SECURITY_POLICY}; + use sqlx::PgPool; + + use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; + + const SPA_POLICY: &str = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data:; connect-src 'self'; worker-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"; + + /// The routes rendering the SPA shell carry the SPA shell policy + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + // Without a session this redirects to the login page, but the header is + // set on the route, not by the handler + let response = state.request(Request::get("/account/").empty()).await; + response.assert_status(StatusCode::SEE_OTHER); + response.assert_header_value(CONTENT_SECURITY_POLICY, SPA_POLICY); + + // This one renders the shell without a session + let response = state + .request(Request::get("/account/password/recovery?ticket=whatever").empty()) + .await; + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_SECURITY_POLICY, SPA_POLICY); + } +} diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 98fe1a88e..fbfafdf06 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -464,7 +464,7 @@ async fn render( mod test { use hyper::{ Request, StatusCode, - header::{CONTENT_TYPE, LOCATION, X_FRAME_OPTIONS}, + header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, LOCATION, X_FRAME_OPTIONS}, }; use mas_data_model::{ UpstreamOAuthProviderClaimsImports, UpstreamOAuthProviderOnBackchannelLogout, @@ -1287,4 +1287,18 @@ mod test { response.assert_status(StatusCode::OK); response.assert_header_value(X_FRAME_OPTIONS, "DENY"); } + + /// Server-rendered human-facing pages carry the human page policy + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let response = state.request(Request::get("/login").empty()).await; + response.assert_status(StatusCode::OK); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' https:; connect-src 'self'; worker-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'", + ); + } } diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 48b8d184c..1592c5e0e 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -435,7 +435,7 @@ async fn render( mod tests { use hyper::{ Request, StatusCode, - header::{CONTENT_TYPE, LOCATION}, + header::{CONTENT_SECURITY_POLICY, CONTENT_TYPE, LOCATION}, }; use mas_router::Route; use sqlx::PgPool; @@ -480,6 +480,24 @@ mod tests { } /// Test the registration happy path + /// This is the only page which loads a captcha, so it is the only one + /// which trusts the provider's origins + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_content_security_policy(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + + let request = + Request::get(&*mas_router::PasswordRegister::default().path_and_query()).empty(); + let response = state.request(request).await; + + response.assert_status(StatusCode::OK); + response.assert_header_value( + CONTENT_SECURITY_POLICY, + state.csp.register().to_str().unwrap(), + ); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_register(pool: PgPool) { setup();