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:
Quentin Gliech
2026-08-10 14:39:37 +02:00
parent efc98a48d2
commit ec014867e7
13 changed files with 345 additions and 55 deletions
+10 -3
View File
@@ -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()
+4 -1
View File
@@ -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
View File
@@ -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((
+44 -9
View File
@@ -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'",
);
}
}
+2 -1
View File
@@ -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
View File
@@ -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());
}
}
+21 -1
View File
@@ -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");
}
}
+33 -5
View File
@@ -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(),
);
}
}
+30
View File
@@ -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);
}
}
+15 -1
View File
@@ -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'",
);
}
}
+19 -1
View File
@@ -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();