Add a builder for per-route Content-Security-Policy headers

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.
This commit is contained in:
Quentin Gliech
2026-08-10 14:39:36 +02:00
parent c47540c850
commit efc98a48d2
5 changed files with 956 additions and 2 deletions
+7 -1
View File
@@ -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<String>,
+943
View File
@@ -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<Cow<'static, str>>) -> 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<Self> {
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<Source>,
style: Vec<Source>,
connect: Vec<Source>,
frame: Vec<Source>,
}
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<Source>>);
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<Item = Source>) -> 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<String> = sources.iter().map(Source::to_string).collect();
format!("{directive} {}", sources.join(" "))
})
.collect::<Vec<_>>()
.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<Target> {
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<Source> {
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<Source>) -> 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<Source>) -> 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<Source> = 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<Source> = 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 `<style>`
// elements vaul and react-remove-scroll inject at runtime. Keep it
// here: the server-rendered pages are the auth-critical ones and
// must not inherit it, so an island on one of those pages must not
// pull in the drawer.
.allow("style-src", [Source::UnsafeInline])
.allow("font-src", [Source::SameOrigin])
.allow("font-src", assets.iter().cloned())
.allow("img-src", [Source::SameOrigin])
.allow("img-src", assets.iter().cloned())
// `https:` is for the client `logo_uri`; `data:` is for the browser
// logos, imported inline by `BrowserSession.tsx`
.allow("img-src", [Source::scheme("https"), Source::scheme("data")])
// Only the GraphQL endpoint, always a same-origin relative URL.
// Locales are lazy `import()`s, so they go through `script-src`
.allow("connect-src", [Source::SameOrigin])
.allow("worker-src", [Source::Nothing])
.allow("frame-src", plan_iframe)
.allow("form-action", [Source::SameOrigin])
.allow("frame-ancestors", [Source::Nothing])
.allow("base-uri", [Source::Nothing])
.allow("object-src", [Source::Nothing])
.finish();
// Swagger UI is a large third-party bundle, so it gets its own policy
// to quarantine anything it needs from the auth-critical pages
let swagger = 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())
// `data:` is for the bundle's CSS backgrounds, `blob:` for the
// response media it renders
.allow("img-src", [Source::scheme("data"), Source::scheme("blob")])
.allow("connect-src", [Source::SameOrigin])
.allow("worker-src", [Source::Nothing])
.allow("form-action", [Source::SameOrigin])
.allow("frame-ancestors", [Source::Nothing])
.allow("base-uri", [Source::Nothing])
.allow("object-src", [Source::Nothing])
.finish();
Self {
// The captcha provider gets a broad grant — hCaptcha asks for a
// whole wildcard domain — so it is scoped to the page that needs
// it rather than handed to every server-rendered page
human: human_policy(
&assets,
&CaptchaSources::default(),
vec![Source::SameOrigin],
)
.finish(),
register: human_policy(&assets, &captcha, vec![Source::SameOrigin]).finish(),
app,
swagger,
locked_down: locked_down_policy().finish(),
assets: assets.into(),
}
}
/// Server-rendered human-facing pages.
#[must_use]
pub fn human(&self) -> HeaderValue {
self.human.clone()
}
/// A server-rendered page which can post a form straight to the client:
/// the "Cancel" button on the consent and policy violation pages, when the
/// grant uses the `form_post` response mode.
#[must_use]
pub fn human_posting_to(&self, redirect_uri: &Url) -> HeaderValue {
// `'self'` is still needed: those pages also post back to us
let mut form_action = vec![Source::SameOrigin];
form_action.extend(form_action_source(redirect_uri));
human_policy(&self.assets, &CaptchaSources::default(), form_action).finish()
}
/// The password registration page, which loads the captcha widget.
#[must_use]
pub fn register(&self) -> HeaderValue {
self.register.clone()
}
/// The account SPA shell.
#[must_use]
pub fn app(&self) -> HeaderValue {
self.app.clone()
}
/// The Swagger UI pages.
#[must_use]
pub fn swagger(&self) -> HeaderValue {
self.swagger.clone()
}
/// Machine endpoints, and the catch-all for every other route.
#[must_use]
pub fn locked_down(&self) -> HeaderValue {
self.locked_down.clone()
}
/// The `form_post` authorization response, which auto-submits a form to the
/// client's redirect URI.
#[must_use]
pub fn form_post(&self, redirect_uri: &Url) -> HeaderValue {
form_post_policy(&self.assets, form_action_source(redirect_uri)).finish()
}
}
impl Default for Csp {
/// The locked-down policy for every kind of route.
///
/// Only for contexts which have no deployment configuration at hand, such
/// as the API schema generator.
fn default() -> Self {
let locked_down = locked_down_policy().finish();
Self {
human: locked_down.clone(),
register: locked_down.clone(),
app: locked_down.clone(),
swagger: locked_down.clone(),
locked_down,
assets: Arc::new([]),
}
}
}
#[cfg(test)]
mod tests {
use mas_data_model::{CaptchaConfig, CaptchaService, SiteConfig};
use mas_router::UrlBuilder;
use super::Csp;
use crate::test_utils::test_site_config;
/// The policies for a deployment with no captcha, no plan iframe and
/// same-origin assets, which every other test varies one input from.
const HUMAN: &str = "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'";
const APP: &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'";
const SWAGGER: &str = "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'";
const LOCKED_DOWN: &str = "default-src 'none'; form-action 'none'; \
frame-ancestors 'none'; base-uri 'none'";
fn site_config(
captcha: Option<CaptchaService>,
plan_management_iframe_uri: Option<&str>,
) -> SiteConfig {
SiteConfig {
captcha: captcha.map(|service| CaptchaConfig {
service,
site_key: "site-key".to_owned(),
secret_key: "secret-key".to_owned(),
}),
plan_management_iframe_uri: plan_management_iframe_uri.map(ToOwned::to_owned),
..test_site_config()
}
}
fn url_builder(assets_base: Option<&str>) -> UrlBuilder {
UrlBuilder::new(
"https://example.com/".parse().unwrap(),
None,
assets_base.map(ToOwned::to_owned),
)
}
#[test]
fn test_no_captcha_no_iframe_same_origin_assets() {
let csp = Csp::new(&site_config(None, None), &url_builder(None));
assert_eq!(csp.human(), HUMAN);
assert_eq!(csp.app(), APP);
assert_eq!(csp.swagger(), SWAGGER);
assert_eq!(csp.locked_down(), LOCKED_DOWN);
}
/// Without a deployment configuration every route gets the locked-down
/// policy
#[test]
fn test_default() {
let csp = Csp::default();
assert_eq!(csp.human(), LOCKED_DOWN);
assert_eq!(csp.app(), LOCKED_DOWN);
assert_eq!(csp.swagger(), LOCKED_DOWN);
assert_eq!(csp.locked_down(), LOCKED_DOWN);
}
#[test]
fn test_cross_origin_assets() {
let csp = Csp::new(
&site_config(None, None),
&url_builder(Some("https://cdn.example.com/assets/")),
);
assert_eq!(
csp.human(),
"default-src 'none'; script-src 'self' https://cdn.example.com; \
style-src 'self' https://cdn.example.com; font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com https:; connect-src 'self'; worker-src 'none'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
assert_eq!(
csp.app(),
"default-src 'none'; script-src 'self' https://cdn.example.com; \
style-src 'self' https://cdn.example.com 'unsafe-inline'; \
font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com https: data:; \
connect-src 'self'; worker-src 'none'; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
assert_eq!(
csp.swagger(),
"default-src 'none'; script-src 'self' https://cdn.example.com; \
style-src 'self' https://cdn.example.com; font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com data: blob:; connect-src 'self'; worker-src 'none'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
assert_eq!(csp.locked_down(), LOCKED_DOWN);
}
/// A non-default port is part of the origin
#[test]
fn test_cross_origin_assets_with_port() {
let csp = Csp::new(
&site_config(None, None),
&url_builder(Some("https://cdn.example.com:8443/assets/")),
);
assert_eq!(
csp.human(),
"default-src 'none'; script-src 'self' https://cdn.example.com:8443; \
style-src 'self' https://cdn.example.com:8443; \
font-src 'self' https://cdn.example.com:8443; \
img-src 'self' https://cdn.example.com:8443 https:; connect-src 'self'; worker-src 'none'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
/// An assets base on our own origin folds into `'self'`, however it is
/// written
#[test]
fn test_same_origin_assets() {
for assets_base in [
None,
Some("/assets/"),
Some("https://example.com/assets/"),
Some("//example.com/assets/"),
] {
let csp = Csp::new(&site_config(None, None), &url_builder(assets_base));
assert_eq!(csp.human(), HUMAN, "assets base {assets_base:?}");
}
}
/// A scheme-relative assets base is cross-origin like any other
#[test]
fn test_scheme_relative_cross_origin_assets() {
let csp = Csp::new(
&site_config(None, None),
&url_builder(Some("//cdn.example.com/assets/")),
);
assert_eq!(
csp.human(),
"default-src 'none'; script-src 'self' https://cdn.example.com; \
style-src 'self' https://cdn.example.com; font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com https:; connect-src 'self'; worker-src 'none'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
/// An assets origin which can't be named by a source expression is dropped
/// rather than emitted for a browser to discard
#[test]
fn test_unnameable_assets() {
for assets_base in ["http://[::1]:8080/assets/", "https://cdn_1.example.com/"] {
let csp = Csp::new(&site_config(None, None), &url_builder(Some(assets_base)));
assert_eq!(csp.human(), HUMAN, "assets base {assets_base:?}");
}
}
#[test]
fn test_recaptcha() {
let csp = Csp::new(
&site_config(Some(CaptchaService::RecaptchaV2), None),
&url_builder(None),
);
assert_eq!(
csp.register(),
"default-src 'none'; \
script-src 'self' https://www.recaptcha.net/recaptcha/ https://www.google.com/recaptcha/ \
https://www.gstatic.com/recaptcha/; \
style-src 'self'; font-src 'self'; img-src 'self' https:; \
connect-src 'self' https://www.recaptcha.net/recaptcha/ https://www.google.com/recaptcha/; \
worker-src 'none'; \
frame-src https://www.recaptcha.net/ https://www.google.com/recaptcha/ \
https://recaptcha.google.com/recaptcha/; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
// No other page loads a captcha, so none of them trusts its origins
assert_eq!(csp.human(), HUMAN);
assert_eq!(csp.app(), APP);
assert_eq!(csp.locked_down(), LOCKED_DOWN);
}
#[test]
fn test_turnstile() {
let csp = Csp::new(
&site_config(Some(CaptchaService::CloudflareTurnstile), None),
&url_builder(None),
);
assert_eq!(
csp.register(),
"default-src 'none'; \
script-src 'self' https://challenges.cloudflare.com/turnstile/v0/; \
style-src 'self'; font-src 'self'; img-src 'self' https:; \
connect-src 'self' https://challenges.cloudflare.com/; worker-src 'none'; \
frame-src https://challenges.cloudflare.com/; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
#[test]
fn test_hcaptcha() {
let csp = Csp::new(
&site_config(Some(CaptchaService::HCaptcha), None),
&url_builder(None),
);
assert_eq!(
csp.register(),
"default-src 'none'; \
script-src 'self' https://hcaptcha.com/ https://*.hcaptcha.com/; \
style-src 'self' https://hcaptcha.com/ https://*.hcaptcha.com/; \
font-src 'self'; img-src 'self' https:; \
connect-src 'self' https://hcaptcha.com/ https://*.hcaptcha.com/; worker-src 'none'; \
frame-src https://hcaptcha.com/ https://*.hcaptcha.com/; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
/// The captcha sources and the assets origin land in the same directives
#[test]
fn test_captcha_with_cross_origin_assets() {
let csp = Csp::new(
&site_config(Some(CaptchaService::HCaptcha), None),
&url_builder(Some("https://cdn.example.com/assets/")),
);
assert_eq!(
csp.register(),
"default-src 'none'; \
script-src 'self' https://cdn.example.com https://hcaptcha.com/ https://*.hcaptcha.com/; \
style-src 'self' https://cdn.example.com https://hcaptcha.com/ https://*.hcaptcha.com/; \
font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com https:; \
connect-src 'self' https://hcaptcha.com/ https://*.hcaptcha.com/; worker-src 'none'; \
frame-src https://hcaptcha.com/ https://*.hcaptcha.com/; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
#[test]
fn test_plan_management_iframe() {
let csp = Csp::new(
&site_config(None, Some("https://plan.example.com:8443/embed?foo=bar")),
&url_builder(None),
);
assert_eq!(
csp.app(),
"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'; \
frame-src https://plan.example.com:8443; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
// Only the SPA shell embeds it
assert_eq!(csp.human(), HUMAN);
}
/// The iframe URI is resolved like the browser resolves it, so a relative
/// one — what a deployment serving the iframe itself uses — is `'self'`
#[test]
fn test_relative_plan_management_iframe() {
for uri in ["/plan", "plan/embed", "https://example.com/plan"] {
let csp = Csp::new(&site_config(None, Some(uri)), &url_builder(None));
assert_eq!(
csp.app(),
"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'; \
frame-src 'self'; form-action 'self'; frame-ancestors 'none'; \
base-uri 'none'; object-src 'none'",
"iframe URI {uri:?}"
);
}
}
/// An iframe URI with no origin a source expression can name is dropped,
/// which blocks the iframe rather than emitting something a browser
/// discards
#[test]
fn test_unnameable_plan_management_iframe() {
for uri in ["data:text/html,hello", "about:blank", "http://[::1]:8080/"] {
let csp = Csp::new(&site_config(None, Some(uri)), &url_builder(None));
assert_eq!(csp.app(), APP, "iframe URI {uri:?}");
}
}
#[test]
fn test_form_post() {
let csp = Csp::new(
&site_config(Some(CaptchaService::HCaptcha), None),
&url_builder(None),
);
// The captcha sources are not in the form_post policy
assert_eq!(
csp.form_post(
&"https://client.example.com/callback?foo=bar"
.parse()
.unwrap()
),
"default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; \
img-src 'self' https:; connect-src 'self'; worker-src 'none'; form-action https://client.example.com; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
// A native client on the loopback interface picks its port at runtime,
// and the port is part of the origin
assert_eq!(
csp.form_post(&"http://127.0.0.1:54321/callback".parse().unwrap()),
"default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; \
img-src 'self' https:; connect-src 'self'; worker-src 'none'; form-action http://127.0.0.1:54321; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
// Native clients also use custom schemes, which have an opaque origin
assert_eq!(
csp.form_post(&"com.example.app:/callback".parse().unwrap()),
"default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; \
img-src 'self' https:; connect-src 'self'; worker-src 'none'; form-action com.example.app:; \
frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
);
}
/// An IPv6 loopback redirect URI can't be named by a CSP source, so the
/// directive is left out instead of emitting one browsers would drop —
/// which would block the submission altogether
#[test]
fn test_form_post_ipv6_loopback() {
let csp = Csp::new(&site_config(None, None), &url_builder(None));
assert_eq!(
csp.form_post(&"http://[::1]:54321/callback".parse().unwrap()),
"default-src 'none'; script-src 'self'; style-src 'self'; font-src 'self'; \
img-src 'self' https:; connect-src 'self'; worker-src 'none'; frame-ancestors 'none'; \
base-uri 'none'; object-src 'none'"
);
}
#[test]
fn test_form_post_with_cross_origin_assets() {
let csp = Csp::new(
&site_config(None, None),
&url_builder(Some("https://cdn.example.com/assets/")),
);
assert_eq!(
csp.form_post(&"https://client.example.com/callback".parse().unwrap()),
"default-src 'none'; script-src 'self' https://cdn.example.com; \
style-src 'self' https://cdn.example.com; font-src 'self' https://cdn.example.com; \
img-src 'self' https://cdn.example.com https:; connect-src 'self'; worker-src 'none'; \
form-action https://client.example.com; frame-ancestors 'none'; base-uri 'none'; \
object-src 'none'"
);
}
/// The consent and policy violation pages post back to us *and*, through
/// the "Cancel" button in `form_post` response mode, straight to the client
#[test]
fn test_human_posting_to() {
let csp = Csp::new(&site_config(None, None), &url_builder(None));
assert_eq!(
csp.human_posting_to(&"https://client.example.com/callback".parse().unwrap()),
"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' https://client.example.com; frame-ancestors 'none'; \
base-uri 'none'; object-src 'none'"
);
// A redirect URI we can't name leaves `form-action` as it would be
// without one, rather than dropping the directive: `'self'` still has
// to be there for the pages' own forms
assert_eq!(
csp.human_posting_to(&"http://[::1]:54321/callback".parse().unwrap()),
csp.human()
);
}
/// The registration page is the only one which trusts the captcha origins
#[test]
fn test_register_is_the_only_page_with_captcha() {
let csp = Csp::new(
&site_config(Some(CaptchaService::HCaptcha), None),
&url_builder(None),
);
assert_eq!(csp.human(), HUMAN);
assert_ne!(csp.register(), csp.human());
assert_eq!(
csp.human_posting_to(&"https://client.example.com/cb".parse().unwrap()),
"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' https://client.example.com; frame-ancestors 'none'; \
base-uri 'none'; object-src 'none'"
);
}
}
+2
View File
@@ -70,6 +70,7 @@ mod captcha;
#[cfg(test)]
mod cleanup_tests;
mod client_ip;
mod csp;
mod preferred_language;
mod rate_limit;
mod session;
@@ -108,6 +109,7 @@ pub use self::{
activity_tracker::{ActivityTracker, Bound as BoundActivityTracker},
admin::router as admin_api_router,
client_ip::ClientIp,
csp::Csp,
graphql::{
GraphQLOperation, Schema as GraphQLSchema, schema as graphql_schema,
schema_builder as graphql_schema_builder,
+1 -1
View File
@@ -2902,7 +2902,7 @@
]
},
"plan_management_iframe_uri": {
"description": "Experimental feature to show a plan management tab and iframe.\nThis value is passed through \"as is\" to the client without any\nvalidation.",
"description": "Experimental feature to show a plan management tab and iframe.\nThis value is passed through \"as is\" to the client without any\nvalidation. It may be relative to the public base URL.\n\nIts origin is allowed to be framed by the `Content-Security-Policy` of\nthe account pages, so a value with no origin at all, such as a `data:`\nURI, makes the browser block the iframe. That is logged as a warning on\nstartup.",
"type": [
"string",
"null"
+3
View File
@@ -1054,6 +1054,9 @@ experimental:
# Experimental feature to show a plan management tab and iframe.
# This value is passed through "as is" to the client without any 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.
#plan_management_iframe_uri: https://example.com/plan
# Experimental feature to limit the number of application sessions per user.