mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-08-14 09:09:47 +00:00
Send a Content-Security-Policy header on every response
Each router gets the policy for the kind of route it serves, applied with `SetResponseHeaderLayer::if_not_present` so that a more specific inner router or handler wins: the SPA shell routes live inside the server-rendered `human_router` and set their own, the password registration page is the only one which trusts the captcha provider's origins, and the two Swagger UI pages are quarantined behind theirs inside the admin API router. The catch-all sits at the top level, next to `X-Content-Type-Options: nosniff` and the existing `X-Frame-Options: DENY`, so nothing can ship headerless by accident. That also gives the compat SSO redirect pages security headers, which they had none of. The 404 page is the one handler which sets its own: it is registered outside every router, so it would otherwise inherit a policy which blocks its own stylesheet. The upstream back-channel logout endpoint moves to `api_router`. It is called by the upstream provider rather than by a browser, and was only in the human router by accident, picking up a page policy, the `X-Frame-Options` header and the HTML error wrapper it has no use for. The `HttpResource` which mounts it changes from `human` to `oauth`. The SPA shell carries a temporary `style-src 'unsafe-inline'` concession for the `<style>` elements vaul and react-remove-scroll inject at runtime, to be removed with the compound-web migration to base-ui. It is deliberately scoped there and not on the server-rendered pages, which are the auth-critical ones.
This commit is contained in:
@@ -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<IpNetwork>,
|
||||
pub limiter: Limiter,
|
||||
pub csp: Csp,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -197,6 +198,12 @@ impl FromRef<AppState> for SiteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Csp {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.csp.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Limiter {
|
||||
fn from_ref(input: &AppState) -> Self {
|
||||
input.limiter.clone()
|
||||
|
||||
@@ -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();
|
||||
|
||||
+33
-10
@@ -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::<AppState>())
|
||||
}
|
||||
mas_config::HttpResource::Human => {
|
||||
router.merge(mas_handlers::human_router::<AppState>(templates.clone()))
|
||||
}
|
||||
mas_config::HttpResource::Human => router.merge(
|
||||
mas_handlers::human_router::<AppState>(templates.clone(), &csp),
|
||||
),
|
||||
mas_config::HttpResource::GraphQL {
|
||||
undocumented_oauth2_access,
|
||||
} => router.merge(mas_handlers::graphql_router::<AppState>(
|
||||
@@ -326,11 +335,11 @@ pub fn build_router(
|
||||
)
|
||||
}
|
||||
mas_config::HttpResource::OAuth => router.merge(mas_handlers::api_router::<AppState>()),
|
||||
mas_config::HttpResource::Compat => {
|
||||
router.merge(mas_handlers::compat_router::<AppState>(templates.clone()))
|
||||
}
|
||||
mas_config::HttpResource::Compat => router.merge(
|
||||
mas_handlers::compat_router::<AppState>(templates.clone(), &csp),
|
||||
),
|
||||
mas_config::HttpResource::AdminApi => {
|
||||
let (_, api_router) = mas_handlers::admin_api_router::<AppState>();
|
||||
let (_, api_router) = mas_handlers::admin_api_router::<AppState>(&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((
|
||||
|
||||
@@ -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<S>() -> (OpenApi, Router<S>)
|
||||
pub fn router<S>(csp: &Csp) -> (OpenApi, Router<S>)
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
Arc<dyn HomeserverConnection>: FromRef<S>,
|
||||
@@ -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'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
|
||||
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>(&mas_handlers::Csp::default());
|
||||
|
||||
// Set the server list to a configurable base URL
|
||||
api.servers = vec![Server {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+82
-22
@@ -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<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
Keystore: FromRef<S>,
|
||||
MetadataCache: FromRef<S>,
|
||||
UrlBuilder: FromRef<S>,
|
||||
BoxRepository: FromRequestParts<S>,
|
||||
ActivityTracker: FromRequestParts<S>,
|
||||
@@ -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<S>(templates: Templates) -> Router<S>
|
||||
pub fn compat_router<S>(templates: Templates, csp: &Csp) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
UrlBuilder: FromRef<S>,
|
||||
@@ -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<S>(templates: Templates) -> Router<S>
|
||||
pub fn human_router<S>(templates: Templates, csp: &Csp) -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
UrlBuilder: FromRef<S>,
|
||||
Csp: FromRef<S>,
|
||||
PreferredLanguage: FromRequestParts<S>,
|
||||
BoxRepository: FromRequestParts<S>,
|
||||
CookieJar: FromRequestParts<S>,
|
||||
@@ -354,7 +365,38 @@ where
|
||||
BoxRng: FromRequestParts<S>,
|
||||
Policy: FromRequestParts<S>,
|
||||
{
|
||||
// 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<UrlBuilder>| {
|
||||
@@ -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<Templates>,
|
||||
State(csp): State<Csp>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MockClock>,
|
||||
pub rng: Arc<Mutex<ChaChaRng>>,
|
||||
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<TestState> for Arc<dyn HomeserverConnection> {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<TestState> for Csp {
|
||||
fn from_ref(input: &TestState) -> Self {
|
||||
input.csp.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<TestState> for Limiter {
|
||||
fn from_ref(input: &TestState) -> Self {
|
||||
input.limiter.clone()
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user