use base64::{engine::general_purpose::STANDARD, Engine as _};
use ring::rand::{SecureRandom, SystemRandom};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CspNonce(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidNonce;
impl fmt::Display for InvalidNonce {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid CSP nonce: must be at least 22 characters of [A-Za-z0-9+/=_-]"
)
}
}
impl std::error::Error for InvalidNonce {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct NonceError;
impl fmt::Display for NonceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "the system random source failed to draw a CSP nonce")
}
}
impl std::error::Error for NonceError {}
const MIN_NONCE_LEN: usize = 22;
impl CspNonce {
pub fn generate() -> Result<Self, NonceError> {
Self::generate_with(|bytes| SystemRandom::new().fill(bytes).map_err(|_| NonceError))
}
fn generate_with(
draw: impl FnOnce(&mut [u8; 32]) -> Result<(), NonceError>,
) -> Result<Self, NonceError> {
let mut bytes = [0u8; 32];
draw(&mut bytes)?;
Ok(CspNonce(STANDARD.encode(bytes)))
}
pub fn try_generate() -> Result<Self, NonceError> {
Self::generate()
}
pub fn parse(value: &str) -> Result<Self, InvalidNonce> {
if value.len() < MIN_NONCE_LEN {
return Err(InvalidNonce);
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-'))
{
return Err(InvalidNonce);
}
Ok(CspNonce(value.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CspNonce {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub fn generate_csp_nonce() -> Result<CspNonce, NonceError> {
CspNonce::generate()
}
fn strict_directives(nonce: &str) -> [(&'static str, Vec<String>); 14] {
let nonce_source = format!("'nonce-{nonce}'");
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
[
("default-src", words(&["'none'"])),
(
"script-src",
vec![
nonce_source.clone(),
"'strict-dynamic'".to_string(),
"'self'".to_string(),
],
),
("style-src", vec![nonce_source, "'self'".to_string()]),
("style-src-attr", words(&["'unsafe-inline'"])),
("worker-src", words(&["'self'"])),
("object-src", words(&["'none'"])),
("connect-src", words(&["'self'"])),
("img-src", words(&["'self'", "data:"])),
("font-src", words(&["'self'"])),
("frame-ancestors", words(&["'none'"])),
("base-uri", words(&["'none'"])),
("form-action", words(&["'self'"])),
("require-trusted-types-for", words(&["'script'"])),
("trusted-types", words(&["forma", "forma-runtime"])),
]
}
fn write_policy<'a>(directives: impl IntoIterator<Item = (&'a str, &'a [String])>) -> String {
let mut out = String::with_capacity(512);
for (name, sources) in directives {
if !out.is_empty() {
out.push_str("; ");
}
out.push_str(name);
for source in sources {
out.push(' ');
out.push_str(source);
}
}
out
}
pub fn build_csp_header(nonce: &CspNonce) -> String {
let directives = strict_directives(nonce.as_str());
write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())))
}
const TRUSTED_TYPES: [(&str, &[&str]); 2] = [
("require-trusted-types-for", &["'script'"]),
("trusted-types", &["forma", "forma-runtime"]),
];
pub(crate) const REPORTING_GROUP: &str = "forma-csp";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Reporting {
Both(String),
UriOnly(String),
}
impl Reporting {
fn endpoint(&self) -> &str {
match self {
Reporting::Both(endpoint) | Reporting::UriOnly(endpoint) => endpoint,
}
}
fn method(&self) -> &'static str {
match self {
Reporting::Both(_) => "report-to",
Reporting::UriOnly(_) => "report-uri",
}
}
fn directives(&self) -> Vec<(&'static str, Vec<String>)> {
let uri = ("report-uri", vec![self.endpoint().to_string()]);
match self {
Reporting::Both(_) => vec![("report-to", vec![REPORTING_GROUP.to_string()]), uri],
Reporting::UriOnly(_) => vec![uri],
}
}
}
fn report_only_text(reporting: Option<&Reporting>) -> String {
let mut directives: Vec<(&str, Vec<String>)> = TRUSTED_TYPES
.iter()
.map(|(name, sources)| (*name, sources.iter().map(|s| s.to_string()).collect()))
.collect();
if let Some(reporting) = reporting {
directives.extend(reporting.directives());
}
write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())))
}
pub(crate) fn report_only_policy() -> Option<String> {
cfg!(debug_assertions).then(|| report_only_text(None))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PolicyExtensions {
img_src: Vec<String>,
frame_src: Vec<String>,
form_action: Vec<String>,
allow_popups: bool,
reporting: Option<Reporting>,
relax_trusted_types: Option<String>,
}
impl PolicyExtensions {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn img_src(mut self, source: impl Into<String>) -> Self {
self.img_src.push(source.into());
self
}
#[must_use]
pub fn frame_src(mut self, source: impl Into<String>) -> Self {
self.frame_src.push(source.into());
self
}
#[must_use]
pub fn form_action(mut self, source: impl Into<String>) -> Self {
self.form_action.push(source.into());
self
}
#[must_use]
pub fn allow_popups(mut self) -> Self {
self.allow_popups = true;
self
}
#[must_use]
pub fn report_to(mut self, endpoint: impl Into<String>) -> Self {
self.reporting = Some(Reporting::Both(endpoint.into()));
self
}
#[must_use]
pub fn report_uri(mut self, endpoint: impl Into<String>) -> Self {
self.reporting = Some(Reporting::UriOnly(endpoint.into()));
self
}
#[must_use]
pub fn relax_trusted_types(mut self, reason: impl Into<String>) -> Self {
self.relax_trusted_types = Some(reason.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyError {
directive: &'static str,
source: String,
reason: &'static str,
}
impl PolicyError {
pub fn directive(&self) -> &'static str {
self.directive
}
pub fn source_expression(&self) -> &str {
&self.source
}
pub fn reason(&self) -> &'static str {
self.reason
}
}
impl fmt::Display for PolicyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid {} extension {:?}: {}",
self.directive, self.source, self.reason
)
}
}
impl std::error::Error for PolicyError {}
#[derive(Debug, Clone)]
pub(crate) struct PagePolicy {
csp: axum::http::HeaderValue,
report_only: Option<axum::http::HeaderValue>,
reporting_endpoints: Option<axum::http::HeaderValue>,
relaxed: Option<String>,
opener: axum::http::HeaderValue,
}
impl PagePolicy {
pub(crate) fn csp(&self) -> &axum::http::HeaderValue {
&self.csp
}
pub(crate) fn report_only(&self) -> Option<&axum::http::HeaderValue> {
self.report_only.as_ref()
}
pub(crate) fn reporting_endpoints(&self) -> Option<&axum::http::HeaderValue> {
self.reporting_endpoints.as_ref()
}
pub(crate) fn relaxed(&self) -> Option<&str> {
self.relaxed.as_deref()
}
pub(crate) fn opener(&self) -> &axum::http::HeaderValue {
&self.opener
}
}
pub(crate) fn page_policy(
nonce: &CspNonce,
extensions: &PolicyExtensions,
) -> Result<PagePolicy, PolicyError> {
let added: [(&'static str, &[String]); 3] = [
("img-src", &extensions.img_src),
("frame-src", &extensions.frame_src),
("form-action", &extensions.form_action),
];
for (directive, sources) in added {
for source in sources {
check_source(directive, source)?;
}
}
if let Some(reporting) = &extensions.reporting {
check_endpoint(reporting.method(), reporting.endpoint())?;
}
if let Some(reason) = &extensions.relax_trusted_types {
if reason.trim().is_empty() {
return Err(PolicyError {
directive: "trusted-types",
source: reason.clone(),
reason: "a relaxation names why, and on the allowlist its owner and end date",
});
}
}
let mut directives: Vec<(&'static str, Vec<String>)> =
strict_directives(nonce.as_str()).into_iter().collect();
if extensions.relax_trusted_types.is_some() {
directives.retain(|(name, _)| !TRUSTED_TYPES.iter().any(|(tt, _)| tt == name));
}
for (directive, sources) in added {
if sources.is_empty() {
continue;
}
let index = match directives.iter().position(|(n, _)| *n == directive) {
Some(index) => index,
None => {
directives.push((directive, Vec::new()));
directives.len() - 1
}
};
if let Some((_, list)) = directives.get_mut(index) {
for source in sources {
if !list.contains(source) {
list.push(source.clone());
}
}
}
}
let reporting = extensions.reporting.as_ref();
if let Some(reporting) = reporting {
directives.extend(reporting.directives());
}
let header = |directive: &'static str, text: String| {
axum::http::HeaderValue::from_str(&text).map_err(|_| PolicyError {
directive,
source: text,
reason: "not a valid header value",
})
};
let csp = header(
"content-security-policy",
write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice()))),
)?;
let report_only = match reporting {
Some(reporting) => Some(report_only_text(Some(reporting))),
None if extensions.relax_trusted_types.is_some() => Some(report_only_text(None)),
None => report_only_policy(),
}
.map(|text| header("content-security-policy-report-only", text))
.transpose()?;
let reporting_endpoints = match reporting {
Some(Reporting::Both(endpoint)) => Some(header(
"report-to",
format!("{REPORTING_GROUP}=\"{endpoint}\""),
)?),
Some(Reporting::UriOnly(_)) | None => None,
};
let opener = axum::http::HeaderValue::from_static(if extensions.allow_popups {
"same-origin-allow-popups"
} else {
"same-origin"
});
Ok(PagePolicy {
csp,
report_only,
reporting_endpoints,
relaxed: extensions.relax_trusted_types.clone(),
opener,
})
}
const RELAXATIONS_LOGGED: usize = 1024;
pub(crate) fn log_relaxation(route: &str, reason: &str) {
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Mutex;
struct Logged {
pairs: BTreeMap<String, BTreeSet<String>>,
count: usize,
}
static LOGGED: Mutex<Logged> = Mutex::new(Logged {
pairs: BTreeMap::new(),
count: 0,
});
let mut logged = LOGGED
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if logged
.pairs
.get(route)
.is_some_and(|reasons| reasons.contains(reason))
{
return;
}
if logged.count > RELAXATIONS_LOGGED {
return;
}
logged.count += 1;
if logged.count > RELAXATIONS_LOGGED {
tracing::warn!(
"Trusted Types relaxed on more than {RELAXATIONS_LOGGED} (route, reason) pairs; \
further ones are not logged. route_pattern is a route's pattern (/users/:id), \
not a request's path."
);
return;
}
logged
.pairs
.entry(route.to_string())
.or_default()
.insert(reason.to_string());
drop(logged);
tracing::warn!(
route,
reason,
"Trusted Types relaxed on route {route}: {reason}. An allowlisted exit: this route \
keeps Trusted Types in Report-Only while other routes enforce. Logged once per \
route and reason per process."
);
}
fn check_endpoint(directive: &'static str, endpoint: &str) -> Result<(), PolicyError> {
let refuse = |reason| {
Err(PolicyError {
directive,
source: endpoint.to_string(),
reason,
})
};
if endpoint.is_empty() {
return refuse("empty endpoint");
}
if endpoint.len() > 2048 {
return refuse("longer than 2048 bytes");
}
let chars_ok = endpoint.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b':'
| b'/'
| b'?'
| b'['
| b']'
| b'@'
| b'!'
| b'$'
| b'&'
| b'('
| b')'
| b'*'
| b'+'
| b'='
| b'%'
)
});
if !chars_ok {
return refuse(
"an endpoint is visible ASCII URL characters, without ';', ',', quotes, a fragment \
or whitespace",
);
}
if endpoint.starts_with("//") {
return refuse("a scheme-relative endpoint; write https:// or a path");
}
if endpoint.starts_with('/') {
return Ok(());
}
let lower = endpoint.to_ascii_lowercase();
let (secure, rest) = if let Some(rest) = lower.strip_prefix("https://") {
(true, rest)
} else if let Some(rest) = lower.strip_prefix("http://") {
(false, rest)
} else {
return refuse(
"an endpoint is a path on the page's origin (/csp-reports) or an https:// URL",
);
};
let Some(host) = endpoint_host(rest) else {
return refuse(
"an endpoint's authority is a host (letters, digits, '.' and '-', or a bracketed \
IPv6 address) and an optional port of digits: no userinfo ('@'), nothing else",
);
};
if secure {
return Ok(());
}
if !matches!(host, "localhost" | "127.0.0.1" | "[::1]") {
return refuse(
"http:// only on localhost, 127.0.0.1 or [::1]: browsers send reports only to a \
secure endpoint",
);
}
if cfg!(debug_assertions) {
Ok(())
} else {
refuse(
"a loopback http:// endpoint in a release build would send each visitor's reports to \
their own machine; name a path on the page's origin (/csp-reports) or an https:// URL",
)
}
}
fn endpoint_host(rest: &str) -> Option<&str> {
let authority = rest.split(['/', '?']).next().unwrap_or("");
if authority.contains('@') {
return None;
}
let (host, port) = if authority.starts_with('[') {
let close = authority.find(']')?;
let (host, after) = authority.split_at(close + 1);
let literal = &host[1..host.len() - 1];
if literal.parse::<std::net::Ipv6Addr>().is_err() {
return None;
}
match after {
"" => (host, None),
_ => (host, Some(after.strip_prefix(':')?)),
}
} else {
match authority.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
}
};
let host_ok = host.starts_with('[') || is_reg_name(host);
let port_ok = port.is_none_or(is_port);
(host_ok && port_ok).then_some(host)
}
fn is_reg_name(host: &str) -> bool {
!host.is_empty()
&& host.split('.').all(|label| {
!label.is_empty()
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
}
fn is_port(port: &str) -> bool {
!port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
&& !port.starts_with('0')
&& port.parse::<u16>().is_ok()
}
pub(crate) fn frame_policy(
nonce: &CspNonce,
runtime_sha256: &str,
app_origin: &str,
) -> Option<PagePolicy> {
let mut directives: Vec<(&str, Vec<String>)> =
strict_directives(nonce.as_str()).into_iter().collect();
let none = || vec!["'none'".to_string()];
for (name, sources) in directives.iter_mut() {
match *name {
"script-src" => *sources = vec![format!("'sha256-{runtime_sha256}'")],
"style-src" => *sources = vec![format!("'nonce-{}'", nonce.as_str())],
"worker-src" | "connect-src" | "font-src" | "form-action" => *sources = none(),
"img-src" => *sources = vec!["data:".to_string()],
"frame-ancestors" => *sources = vec![app_origin.to_string()],
_ => {}
}
}
directives.push(("sandbox", vec!["allow-scripts".to_string()]));
let csp = write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())));
Some(PagePolicy {
csp: axum::http::HeaderValue::from_str(&csp).ok()?,
report_only: match report_only_policy() {
Some(text) => Some(axum::http::HeaderValue::from_str(&text).ok()?),
None => None,
},
reporting_endpoints: None,
relaxed: None,
opener: axum::http::HeaderValue::from_static("same-origin"),
})
}
pub(crate) fn check_origin(origin: &str) -> Result<String, &'static str> {
let lower = origin.to_ascii_lowercase();
let (secure, authority) = if let Some(rest) = lower.strip_prefix("https://") {
(true, rest)
} else if let Some(rest) = lower.strip_prefix("http://") {
(false, rest)
} else {
return Err("an origin is https://host[:port], or http:// on a loopback host");
};
let (host, port) = if authority.starts_with('[') {
match authority.split_once(']') {
Some((v6, rest)) => {
if !rest.is_empty() && !rest.starts_with(':') {
return Err("an origin is a scheme, a host and an optional port, nothing more");
}
(format!("{v6}]"), rest.strip_prefix(':'))
}
None => return Err("an unclosed IPv6 host"),
}
} else {
match authority.split_once(':') {
Some((host, port)) => (host.to_string(), Some(port)),
None => (authority.to_string(), None),
}
};
let host_ok = host == "[::1]" || is_reg_name(&host);
let port_ok = port.is_none_or(is_port);
if !host_ok || !port_ok {
return Err("an origin is a scheme, a host and an optional port, nothing more");
}
if !secure && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") {
return Err(
"http:// only on localhost, 127.0.0.1 or [::1]; serve the application over https",
);
}
Ok(lower)
}
fn check_source(directive: &'static str, source: &str) -> Result<(), PolicyError> {
let refuse = |reason| {
Err(PolicyError {
directive,
source: source.to_string(),
reason,
})
};
const WILDCARD: &str = "a bare * allows every host; name a scheme (https:) or a host";
if source.is_empty() {
return refuse("empty source");
}
if source.len() > 1024 {
return refuse("longer than 1024 bytes");
}
if source == "'self'" {
return Ok(());
}
if source.starts_with('\'') {
return refuse(
"only 'self' may be added; nonces, hashes, 'none', 'strict-dynamic' and \
'unsafe-*' stay the policy's own",
);
}
if source == "*" {
return refuse(WILDCARD);
}
if let Some(scheme) = source.strip_suffix(':') {
if is_scheme(scheme) {
return match refused_scheme(directive, scheme) {
Some(reason) => refuse(reason),
None => Ok(()),
};
}
}
let rest = match source.split_once("://") {
Some((scheme, rest)) if is_scheme(scheme) => {
if let Some(reason) = refused_scheme(directive, scheme) {
return refuse(reason);
}
rest
}
Some(_) => return refuse("not a CSP source expression"),
None => source,
};
let (authority, path) = match rest.find('/') {
Some(at) => rest.split_at(at),
None => (rest, ""),
};
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
};
if host == "*" {
return refuse(WILDCARD);
}
let labels = host.strip_prefix("*.").unwrap_or(host);
let host_ok = !labels.is_empty()
&& labels.split('.').all(|label| {
!label.is_empty()
&& label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
});
let port_ok = match port {
None => true,
Some(p) => p == "*" || (!p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())),
};
let path_ok = path.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'/' | b'-'
| b'.'
| b'_'
| b'~'
| b'%'
| b'!'
| b'$'
| b'&'
| b'('
| b')'
| b'*'
| b'+'
| b'='
| b':'
| b'@'
)
});
if host_ok && port_ok && path_ok {
Ok(())
} else {
refuse("not a CSP source expression")
}
}
fn refused_scheme(directive: &str, scheme: &str) -> Option<&'static str> {
let scheme = scheme.to_ascii_lowercase();
if scheme == "javascript" || scheme == "vbscript" {
return Some("a script scheme is never a source");
}
if matches!(directive, "frame-src" | "form-action")
&& (scheme == "data" || scheme == "filesystem")
{
return Some("data: and filesystem: are not a frame or form target");
}
None
}
fn is_scheme(s: &str) -> bool {
let mut bytes = s.bytes();
matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic())
&& bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn directive<'a>(csp: &'a str, name: &str) -> Option<&'a str> {
csp.split(';')
.map(str::trim)
.find_map(|d| d.strip_prefix(name).filter(|rest| rest.starts_with(' ')))
.map(str::trim)
}
#[test]
fn nonce_is_unique() {
let a = generate_csp_nonce().unwrap();
let b = generate_csp_nonce().unwrap();
assert_ne!(a, b);
}
#[test]
fn try_generate_draws_distinct_44_character_nonces() {
let a = CspNonce::try_generate().unwrap();
let b = CspNonce::try_generate().unwrap();
assert_ne!(a, b);
assert_eq!(a.as_str().len(), 44);
assert_eq!(CspNonce::parse(a.as_str()), Ok(a));
}
#[test]
fn nonce_is_base64() {
let nonce = generate_csp_nonce().unwrap();
assert_eq!(nonce.as_str().len(), 44); assert!(STANDARD.decode(nonce.as_str()).is_ok());
}
#[test]
fn csp_contains_nonce() {
let nonce = generate_csp_nonce().unwrap();
let csp = build_csp_header(&nonce);
assert!(csp.contains(&format!("'nonce-{nonce}'")));
}
#[test]
fn parse_rejects_attribute_and_directive_breakout() {
for bad in [
"\" onload=\"alert(1)",
"x' 'unsafe-inline",
"short",
"has spaces in it here",
"semi;colon;value;padding",
] {
assert_eq!(CspNonce::parse(bad), Err(InvalidNonce), "accepted {bad:?}");
}
}
#[test]
fn parse_accepts_a_generated_nonce() {
let generated = CspNonce::generate().unwrap();
assert_eq!(CspNonce::parse(generated.as_str()), Ok(generated));
}
#[test]
fn failed_draw_returns_no_nonce_even_after_a_partial_fill() {
let result = CspNonce::generate_with(|bytes| {
bytes[..8].fill(0x61);
Err(NonceError)
});
assert_eq!(result, Err(NonceError));
}
#[test]
fn successful_draw_encodes_all_32_bytes_once() {
let mut calls = 0;
let nonce = CspNonce::generate_with(|bytes| {
calls += 1;
bytes.fill(0x61);
Ok(())
})
.unwrap();
assert_eq!(calls, 1);
assert_eq!(STANDARD.decode(nonce.as_str()).unwrap(), vec![0x61; 32]);
assert_eq!(CspNonce::parse(nonce.as_str()), Ok(nonce));
}
#[test]
fn strict_base_preserves_existing_directives_and_adds_enforced_trusted_types() {
let nonce = CspNonce::parse("test-nonce-value-0123456789").unwrap();
assert_eq!(
build_csp_header(&nonce),
"default-src 'none'; \
script-src 'nonce-test-nonce-value-0123456789' 'strict-dynamic' 'self'; \
style-src 'nonce-test-nonce-value-0123456789' 'self'; \
style-src-attr 'unsafe-inline'; \
worker-src 'self'; \
object-src 'none'; \
connect-src 'self'; \
img-src 'self' data:; \
font-src 'self'; \
frame-ancestors 'none'; \
base-uri 'none'; \
form-action 'self'; require-trusted-types-for 'script'; trusted-types forma forma-runtime"
);
}
#[test]
fn extension_sources_accept_what_consumers_add() {
for source in [
"'self'",
"https:",
"http:",
"blob:",
"data:",
"wss:",
"cdn.example.com",
"https://cdn.example.com",
"https://*.example.com",
"https://cdn.example.com:8443",
"https://cdn.example.com:*",
"https://cdn.example.com/images/",
"https://cdn.example.com/a-b_c~d%20e/f.png",
"localhost:3000",
"127.0.0.1:8080",
] {
assert_eq!(check_source("img-src", source), Ok(()), "{source:?}");
}
}
#[test]
fn extension_sources_refuse_injection_and_loosening() {
for (source, why) in [
("", "empty"),
("*", "every host"),
("https://*", "every host"),
("'none'", "only 'self'"),
("'unsafe-inline'", "only 'self'"),
("'unsafe-eval'", "only 'self'"),
("'unsafe-hashes'", "only 'self'"),
("'strict-dynamic'", "only 'self'"),
("'nonce-abcdefghijklmnopqrstuv'", "only 'self'"),
("'sha256-abc='", "only 'self'"),
("'SELF'", "only 'self'"),
("javascript:", "script scheme"),
("JavaScript:", "script scheme"),
("vbscript:", "script scheme"),
("javascript://x", "script scheme"),
("JAVASCRIPT://x/%0aalert(1)", "script scheme"),
("vbscript://x/%0aalert(1)", "script scheme"),
("VBScript://x", "script scheme"),
("https://a.example; script-src *", "source expression"),
("https://a.example;", "source expression"),
("https://a.example,b.example", "source expression"),
("https://a.example b.example", "source expression"),
("https://a.example\tb", "source expression"),
("https://a.example\r\nx: y", "source expression"),
("https://a.example/'x'", "source expression"),
("https://a.example/\"x\"", "source expression"),
("https://a..example", "source expression"),
("https://a.example:80x", "source expression"),
("https://a.example:", "source expression"),
("1https://a.example", "source expression"),
("ht tp://a.example", "source expression"),
("https://", "source expression"),
("https://[::1]", "source expression"),
("a.example/ΓΌ", "source expression"),
] {
let error = check_source("frame-src", source).expect_err(source);
assert_eq!(error.directive(), "frame-src");
assert_eq!(error.source_expression(), source);
assert!(
error.reason().contains(why),
"{source:?}: {} should say {why:?}",
error.reason()
);
}
let long = format!("https://{}.example", "a".repeat(1100));
assert!(check_source("img-src", &long).is_err());
}
#[test]
fn frame_and_form_targets_refuse_data_and_filesystem() {
for directive in ["frame-src", "form-action"] {
for source in [
"data:",
"DATA:",
"data://x",
"filesystem:",
"FileSystem://x/y",
] {
let error = check_source(directive, source).expect_err(source);
assert!(
error.reason().contains("frame or form"),
"{directive} {source:?}: {}",
error.reason()
);
}
}
for (directive, source) in [
("img-src", "data:"),
("img-src", "blob:"),
("frame-src", "blob:"),
("frame-src", "'self'"),
("frame-src", "https:"),
("form-action", "https://idp.example.com"),
] {
assert_eq!(
check_source(directive, source),
Ok(()),
"{directive} {source}"
);
}
}
#[test]
fn csp_is_strict() {
let csp = build_csp_header(&CspNonce::parse("test-nonce-value-0123456789").unwrap());
assert_eq!(directive(&csp, "default-src"), Some("'none'"));
let script = directive(&csp, "script-src").expect("script-src");
assert!(script.contains("'nonce-"));
assert!(
script.contains("'strict-dynamic'"),
"without strict-dynamic the 'self' host-source is still honoured"
);
assert!(!script.contains("unsafe-inline"));
assert!(!script.contains("unsafe-eval"));
assert!(!csp.contains("wasm-unsafe-eval"));
let style = directive(&csp, "style-src").expect("style-src");
assert!(style.contains("'nonce-"));
assert!(!style.contains("unsafe-inline"));
assert_eq!(directive(&csp, "style-src-attr"), Some("'unsafe-inline'"));
assert_eq!(directive(&csp, "style-src-elem"), None);
assert_eq!(directive(&csp, "worker-src"), Some("'self'"));
assert_eq!(directive(&csp, "object-src"), Some("'none'"));
let nonce = CspNonce::parse("test-nonce-value-0123456789").unwrap();
let extended = page_policy(
&nonce,
&PolicyExtensions::new()
.img_src("https:")
.frame_src("'self'")
.form_action("https://idp.example.com")
.allow_popups()
.report_to("/csp-reports")
.relax_trusted_types("a page not moved yet"),
)
.unwrap();
let extended_csp = extended.csp.to_str().unwrap();
for policy in [csp.as_str(), extended_csp] {
assert_eq!(policy.matches("'unsafe-inline'").count(), 1, "{policy}");
assert_eq!(directive(policy, "style-src-attr"), Some("'unsafe-inline'"));
assert!(!policy.contains("unsafe-eval") && !policy.contains("unsafe-hashes"));
}
let report_only = report_only_text(None);
assert_eq!(
report_only,
"require-trusted-types-for 'script'; trusted-types forma forma-runtime"
);
assert_eq!(
directive(&report_only, "require-trusted-types-for"),
Some("'script'")
);
assert_eq!(
directive(&report_only, "trusted-types"),
Some("forma forma-runtime")
);
assert!(!report_only.contains("unsafe") && !report_only.contains("allow-duplicates"));
let extended_report_only = extended.report_only.as_ref().unwrap().to_str().unwrap();
assert!(
extended_report_only.starts_with(&report_only),
"{extended_report_only}"
);
assert!(!extended_report_only.contains("unsafe"));
assert_eq!(
directive(&csp, "require-trusted-types-for"),
Some("'script'")
);
assert_eq!(
directive(&csp, "trusted-types"),
Some("forma forma-runtime")
);
assert_eq!(directive(extended_csp, "require-trusted-types-for"), None);
assert_eq!(directive(extended_csp, "trusted-types"), None);
}
#[test]
fn endpoints_accept_a_path_https_or_loopback_http_and_refuse_the_rest() {
for endpoint in [
"/csp-reports",
"/r?app=studio&v=1",
"/a-b_c.d~e/%20f",
"https://reports.example.com/csp",
"HTTPS://Reports.Example.com/x",
"https://reports.example.com:8443/csp?x=1",
"https://reports.example.com?app=studio",
"https://127.0.0.1:8443/csp",
"https://[2001:db8::1]:8443/csp",
"https://[::ffff:192.0.2.1]/csp",
"https://reports.example:65535/csp",
] {
assert_eq!(
check_endpoint("report-to", endpoint),
Ok(()),
"{endpoint:?}"
);
}
for endpoint in [
"http://localhost:3000/csp",
"http://127.0.0.1/csp",
"http://[::1]:8080/csp",
"HTTP://LOCALHOST/csp",
] {
let checked = check_endpoint("report-uri", endpoint);
if cfg!(debug_assertions) {
assert_eq!(checked, Ok(()), "{endpoint:?}");
} else {
let error = checked.expect_err(endpoint);
assert!(error.reason().contains("release build"), "{error}");
}
}
for endpoint in [
"",
"csp-reports",
"//evil.example/x",
"http://evil.example/x",
"http://localhost.evil.example/x",
"http://localhost@evil.example/x",
"http://127.0.0.1.evil.example/x",
"https://",
"https:///x",
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:,x",
"ftp://reports.example.com/x",
"/a;script-src *",
"/a; script-src *",
"/a,b",
"/a b",
"/a\tb",
"/a\nb",
"/a\"b",
"/a'b",
"/a\\b",
"/a#frag",
"/ΓΌ",
"/a<b>",
"https://user:pw@reports.example/csp",
"https://user@reports.example",
"https://@reports.example/csp",
"http://evil.com@localhost/csp",
"http://[::1]evil.com",
"http://[::1",
"http://localhost:3000]",
"https://reports.example:8443x",
"https://reports.example:",
"https://reports.example:123456",
"https://%0d%0a",
"https://]",
"https://[]",
"https://[zz::1]",
"https://[::1]x:80",
"https://reports..example/csp",
"https://.example/csp",
"https://reports_example/csp",
"https://*.example/csp",
"https://reports.example:8443:1/csp",
"https://reports.example:0/csp",
"https://reports.example:65536/csp",
"https://reports.example:00443/csp",
"https://[::1::2]/csp",
"https://[12345::1]/csp",
"https://[::1%25eth0]/csp",
"https://-/csp",
"https://-reports.example/csp",
] {
let error = check_endpoint("report-to", endpoint).expect_err(endpoint);
assert_eq!(error.directive(), "report-to", "{endpoint:?}");
assert_eq!(error.source_expression(), endpoint);
}
let long = format!("/{}", "a".repeat(2048));
assert!(check_endpoint("report-uri", &long).is_err());
}
}