From efc98a48d23958b729ae1fbfa30e539da7962c7e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 7 Aug 2026 19:40:59 +0200 Subject: [PATCH] Add a builder for per-route `Content-Security-Policy` headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than one lowest-common-denominator policy, each kind of route gets the strictest policy it can bear: the server-rendered pages, the account SPA shell, the Swagger UI, and a locked-down catch-all for everything else. They are computed once at startup from the site configuration and the `UrlBuilder` — the captcha provider origins, the plan-management iframe origin and a possible cross-origin assets host are the only dynamic inputs — and stored as prebuilt `HeaderValue`s. The `form_post` authorization response is the one per-response case, as its `form-action` names the redirect URI of the grant being completed. --- crates/config/src/sections/experimental.rs | 8 +- crates/handlers/src/csp.rs | 943 +++++++++++++++++++++ crates/handlers/src/lib.rs | 2 + docs/config.schema.json | 2 +- docs/reference/configuration.md | 3 + 5 files changed, 956 insertions(+), 2 deletions(-) create mode 100644 crates/handlers/src/csp.rs diff --git a/crates/config/src/sections/experimental.rs b/crates/config/src/sections/experimental.rs index ea4ac6716..bef3a2881 100644 --- a/crates/config/src/sections/experimental.rs +++ b/crates/config/src/sections/experimental.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -84,7 +85,12 @@ pub struct ExperimentalConfig { /// Experimental feature to show a plan management tab and iframe. /// This value is passed through "as is" to the client without any - /// validation. + /// validation. It may be relative to the public base URL. + /// + /// Its origin is allowed to be framed by the `Content-Security-Policy` of + /// the account pages, so a value with no origin at all, such as a `data:` + /// URI, makes the browser block the iframe. That is logged as a warning on + /// startup. #[serde(skip_serializing_if = "Option::is_none")] pub plan_management_iframe_uri: Option, diff --git a/crates/handlers/src/csp.rs b/crates/handlers/src/csp.rs new file mode 100644 index 000000000..73c95e952 --- /dev/null +++ b/crates/handlers/src/csp.rs @@ -0,0 +1,943 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Per-route `Content-Security-Policy` headers. +//! +//! Each kind of route MAS serves gets the strictest policy it can bear, rather +//! than one lowest-common-denominator policy: server-rendered pages, the +//! account SPA shell, the Swagger UI, and everything else. The policies are +//! computed once at startup from the site configuration and the [`UrlBuilder`], +//! and stored as prebuilt [`HeaderValue`]s. +//! +//! The exceptions are the pages which hand the authorization response back to +//! the client by posting a form to its redirect URI: their `form-action` names +//! that URI, so it is built per response. + +use std::{borrow::Cow, fmt, sync::Arc}; + +use http::HeaderValue; +use indexmap::IndexMap; +use mas_data_model::{CaptchaService, SiteConfig}; +use mas_router::UrlBuilder; +use url::{Host, Origin, Url}; + +/// A source expression: what a directive allows. +/// +/// Building one is the only way to get a source into a policy, so a policy +/// can't end up carrying something a browser would fail to parse and drop. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Source { + /// `'none'` + Nothing, + + /// `'self'` + SameOrigin, + + /// `'unsafe-inline'` + UnsafeInline, + + /// Any URL with this scheme, like `https:` + Scheme(Cow<'static, str>), + + /// One origin, or a vendor's documented URL prefix + Url(Cow<'static, str>), +} + +impl Source { + /// Any URL with the given scheme. + fn scheme(scheme: impl Into>) -> Self { + Self::Scheme(scheme.into()) + } + + /// A vendor's documented URL prefix. + fn url(url: &'static str) -> Self { + Self::Url(Cow::Borrowed(url)) + } + + /// One origin, if a source expression can name it at all. + /// + /// A host is `ALPHA / DIGIT / "-"` separated by dots and nothing else, so + /// an IPv6 literal — which loopback redirect URIs are allowed to use — has + /// no representation, and neither does a domain with an underscore in it. + /// A browser drops a source expression it can't parse, so emitting one is + /// worse than emitting nothing. + fn origin(origin: &Origin) -> Option { + let Origin::Tuple(_, host, _) = origin else { + return None; + }; + + let nameable = match host { + Host::Ipv4(_) => true, + Host::Ipv6(_) => false, + Host::Domain(domain) => domain.split('.').all(|label| { + !label.is_empty() + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }), + }; + + nameable.then(|| Self::Url(Cow::Owned(origin.ascii_serialization()))) + } +} + +impl fmt::Display for Source { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nothing => f.write_str("'none'"), + Self::SameOrigin => f.write_str("'self'"), + Self::UnsafeInline => f.write_str("'unsafe-inline'"), + Self::Scheme(scheme) => write!(f, "{scheme}:"), + Self::Url(url) => f.write_str(url), + } + } +} + +/// The third-party sources a captcha provider needs. +#[derive(Default, Clone)] +struct CaptchaSources { + script: Vec, + style: Vec, + connect: Vec, + frame: Vec, +} + +impl CaptchaSources { + fn for_service(service: CaptchaService) -> Self { + match service { + // MAS loads the SDK from the `recaptcha.net` alias, but the SDK + // itself falls back to the `google.com` origins Google's own CSP + // guidance lists, so both are allowed + CaptchaService::RecaptchaV2 => Self { + script: vec![ + Source::url("https://www.recaptcha.net/recaptcha/"), + Source::url("https://www.google.com/recaptcha/"), + Source::url("https://www.gstatic.com/recaptcha/"), + ], + style: Vec::new(), + connect: vec![ + Source::url("https://www.recaptcha.net/recaptcha/"), + Source::url("https://www.google.com/recaptcha/"), + ], + frame: vec![ + Source::url("https://www.recaptcha.net/"), + Source::url("https://www.google.com/recaptcha/"), + Source::url("https://recaptcha.google.com/recaptcha/"), + ], + }, + + CaptchaService::CloudflareTurnstile => Self { + script: vec![Source::url( + "https://challenges.cloudflare.com/turnstile/v0/", + )], + // The SDK styles through CSSOM, which no directive governs + style: Vec::new(), + connect: vec![Source::url("https://challenges.cloudflare.com/")], + frame: vec![Source::url("https://challenges.cloudflare.com/")], + }, + + CaptchaService::HCaptcha => { + // Their SDK spreads over the apex and several subdomains, and a + // `*.` source doesn't match the apex, so their documentation + // asks for the same pair in all four directives + let origins = vec![ + Source::url("https://hcaptcha.com/"), + Source::url("https://*.hcaptcha.com/"), + ]; + + Self { + script: origins.clone(), + style: origins.clone(), + connect: origins.clone(), + frame: origins, + } + } + } + } +} + +/// A policy: which sources each directive allows. +/// +/// Ordered, because the serialization has to be stable — the policies are +/// golden tested, and a header which reshuffles itself between builds is a +/// nuisance to diff and to read in a browser's console. +#[derive(Default)] +struct Policy(IndexMap<&'static str, Vec>); + +impl Policy { + /// Allow some sources for a directive, on top of whatever it already + /// allows. + /// + /// An empty list is a no-op, which is how the optional sources — a + /// cross-origin assets host, the captcha provider, the plan iframe — fall + /// out of a policy which doesn't need them. That only works because every + /// directive which can end up empty here is a fetch directive, covered by + /// `default-src 'none'`; `form-action`, `frame-ancestors` and `base-uri` + /// have no such fallback, and leaving one of those out means unrestricted. + fn allow(mut self, directive: &'static str, sources: impl IntoIterator) -> Self { + let mut sources = sources.into_iter().peekable(); + if sources.peek().is_none() { + return self; + } + + self.0.entry(directive).or_default().extend(sources); + self + } + + /// Every source is either one of the [`Source`] literals, an origin + /// serialization or a URL scheme, so this can't fail: the URL parser + /// rejects the bytes a header value would refuse. + fn finish(self) -> HeaderValue { + let policy = self + .0 + .into_iter() + .map(|(directive, sources)| { + let sources: Vec = sources.iter().map(Source::to_string).collect(); + format!("{directive} {}", sources.join(" ")) + }) + .collect::>() + .join("; "); + + HeaderValue::try_from(policy).expect("policy is a valid header value") + } +} + +/// Where a URI reference from the configuration points, as a policy sees it. +enum Target { + /// Our own origin, which every policy already covers with `'self'` + SameOrigin, + + /// Somewhere else + Other(Source), +} + +/// Resolve a URI reference from the configuration the way a browser will: +/// against the public base URL, so a relative reference — which is how a +/// deployment serving the thing itself writes it — lands on our own origin. +/// +/// `None` when a source expression can't name where it points, in which case +/// the caller leaves it out and whatever it points at is blocked. That is the +/// safe failure, and it means no configured string is ever copied into a +/// header. +fn config_target(uri: &str, url_builder: &UrlBuilder) -> Option { + let base = url_builder.http_base(); + let origin = Url::options() + .base_url(Some(&base)) + .parse(uri) + .ok()? + .origin(); + + if origin == base.origin() { + return Some(Target::SameOrigin); + } + + Source::origin(&origin).map(Target::Other) +} + +/// The `form-action` source for a client redirect URI. +/// +/// `None` when a source expression can't name it, in which case the caller +/// leaves the directive out and form submissions stay unrestricted. That is +/// deliberate: `form-action` has no `default-src` fallback, so a source the +/// browser drops would leave it with nothing valid and block the submission +/// outright. +fn form_action_source(redirect_uri: &Url) -> Option { + match redirect_uri.scheme() { + "http" | "https" => Source::origin(&redirect_uri.origin()), + // Native clients also use custom schemes, which have an opaque origin. + // `form_post` can't actually deliver the parameters to one — the POST + // body is dropped when the browser hands the URL to an external + // protocol handler — but the scheme is the closest we can express. + scheme => Some(Source::scheme(scheme.to_owned())), + } +} + +/// The policy for the server-rendered pages. +/// +/// `form_action` is a parameter because the consent and policy violation pages +/// render a "Cancel" button which, in `form_post` response mode, posts straight +/// to the client's redirect URI. +fn human_policy(assets: &[Source], captcha: &CaptchaSources, form_action: Vec) -> Policy { + Policy::default() + .allow("default-src", [Source::Nothing]) + .allow("script-src", [Source::SameOrigin]) + .allow("script-src", assets.iter().cloned()) + .allow("script-src", captcha.script.iter().cloned()) + .allow("style-src", [Source::SameOrigin]) + .allow("style-src", assets.iter().cloned()) + .allow("style-src", captcha.style.iter().cloned()) + .allow("font-src", [Source::SameOrigin]) + .allow("font-src", assets.iter().cloned()) + .allow("img-src", [Source::SameOrigin]) + .allow("img-src", assets.iter().cloned()) + // for the client `logo_uri`, hot-linked on the consent, device consent + // and policy violation pages + .allow("img-src", [Source::scheme("https")]) + .allow("connect-src", [Source::SameOrigin]) + .allow("connect-src", captcha.connect.iter().cloned()) + // `worker-src` falls back through `child-src` to `script-src`, not to + // `default-src`, so a policy which allows scripts also allows + // registering a service worker unless this says otherwise + .allow("worker-src", [Source::Nothing]) + .allow("frame-src", captcha.frame.iter().cloned()) + .allow("form-action", form_action) + .allow("frame-ancestors", [Source::Nothing]) + .allow("base-uri", [Source::Nothing]) + .allow("object-src", [Source::Nothing]) +} + +/// The policy for the `form_post` authorization response, which auto-submits a +/// form to the client's redirect URI. +/// +/// The server-rendered page policy without the captcha sources, which that page +/// never loads. +fn form_post_policy(assets: &[Source], form_action: Option) -> Policy { + Policy::default() + .allow("default-src", [Source::Nothing]) + .allow("script-src", [Source::SameOrigin]) + .allow("script-src", assets.iter().cloned()) + .allow("style-src", [Source::SameOrigin]) + .allow("style-src", assets.iter().cloned()) + .allow("font-src", [Source::SameOrigin]) + .allow("font-src", assets.iter().cloned()) + .allow("img-src", [Source::SameOrigin]) + .allow("img-src", assets.iter().cloned()) + // for the client `logo_uri`, hot-linked on that page + .allow("img-src", [Source::scheme("https")]) + .allow("connect-src", [Source::SameOrigin]) + .allow("worker-src", [Source::Nothing]) + // With nothing to name, the directive is left out rather than emitted + // with a source the browser would drop — see `form_action_source` + .allow("form-action", form_action) + .allow("frame-ancestors", [Source::Nothing]) + .allow("base-uri", [Source::Nothing]) + .allow("object-src", [Source::Nothing]) +} + +/// Nothing is allowed at all: no subresources, no framing, no form target. +/// +/// This is the policy any route which forgets to set one of its own inherits, +/// so it names the directives `default-src` doesn't cover. +fn locked_down_policy() -> Policy { + Policy::default() + .allow("default-src", [Source::Nothing]) + .allow("form-action", [Source::Nothing]) + .allow("frame-ancestors", [Source::Nothing]) + .allow("base-uri", [Source::Nothing]) +} + +/// The `Content-Security-Policy` headers served by each kind of route. +#[derive(Clone)] +pub struct Csp { + /// Server-rendered human-facing pages: login, recovery, consent, device + /// link, the upstream OAuth pages, the compat SSO redirect, and the error + /// pages. + human: HeaderValue, + + /// The password registration page, the only one which loads a captcha. + register: HeaderValue, + + /// The account SPA shell. + app: HeaderValue, + + /// The Swagger UI pages. + swagger: HeaderValue, + + /// Machine endpoints, and the catch-all every other route falls back to. + locked_down: HeaderValue, + + /// The assets origin, kept around because the policies which name a client + /// redirect URI are built per response. + assets: Arc<[Source]>, +} + +impl Csp { + /// Build the policies for a deployment. + #[must_use] + pub fn new(site_config: &SiteConfig, url_builder: &UrlBuilder) -> Self { + let assets_base = url_builder.assets_base(); + let assets: Vec = match config_target(assets_base, url_builder) { + Some(Target::SameOrigin) => Vec::new(), + Some(Target::Other(source)) => vec![source], + None => { + tracing::warn!( + assets_base, + "Assets base URL has no origin which can be named in a Content-Security-Policy; the assets will be blocked" + ); + Vec::new() + } + }; + + let captcha = site_config + .captcha + .as_ref() + .map(|captcha| CaptchaSources::for_service(captcha.service)) + .unwrap_or_default(); + + // The iframe URI is passed through to the template as-is, so it is + // resolved here exactly as the browser will resolve it there + let plan_iframe: Vec = match site_config.plan_management_iframe_uri.as_deref() { + None => Vec::new(), + Some(uri) => match config_target(uri, url_builder) { + Some(Target::SameOrigin) => vec![Source::SameOrigin], + Some(Target::Other(source)) => vec![source], + None => { + tracing::warn!( + plan_management_iframe_uri = uri, + "Plan management iframe URI has no origin which can be named in a Content-Security-Policy; the iframe will be blocked" + ); + Vec::new() + } + }, + }; + + let app = Policy::default() + .allow("default-src", [Source::Nothing]) + .allow("script-src", [Source::SameOrigin]) + .allow("script-src", assets.iter().cloned()) + .allow("style-src", [Source::SameOrigin]) + .allow("style-src", assets.iter().cloned()) + // `'unsafe-inline'` is a temporary concession for the `