use std::collections::BTreeMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::header::HeaderValue;
use axum::http::{HeaderName, Request, Response};
use axum::middleware::Next;
use axum::Router;
#[derive(Clone)]
pub struct SecurityHeadersLayer {
pub hsts: Option<String>,
pub xfo: Option<&'static str>,
pub nosniff: bool,
pub referrer_policy: Option<&'static str>,
pub coop: Option<&'static str>,
pub permissions_policy: Option<String>,
pub csp: Option<String>,
pub csp_report_only: bool,
pub custom: BTreeMap<String, String>,
}
impl Default for SecurityHeadersLayer {
fn default() -> Self {
Self::strict()
}
}
impl SecurityHeadersLayer {
#[must_use]
pub fn empty() -> Self {
Self {
hsts: None,
xfo: None,
nosniff: false,
referrer_policy: None,
coop: None,
permissions_policy: None,
csp: None,
csp_report_only: false,
custom: BTreeMap::new(),
}
}
#[must_use]
pub fn strict() -> Self {
Self {
hsts: Some("max-age=31536000; includeSubDomains; preload".into()),
xfo: Some("DENY"),
nosniff: true,
referrer_policy: Some("same-origin"),
coop: Some("same-origin"),
permissions_policy: Some("camera=(), microphone=(), geolocation=()".into()),
csp: None,
csp_report_only: false,
custom: BTreeMap::new(),
}
}
#[must_use]
pub fn relaxed() -> Self {
Self {
hsts: Some("max-age=31536000".into()),
xfo: Some("SAMEORIGIN"),
nosniff: true,
referrer_policy: Some("strict-origin-when-cross-origin"),
coop: None,
permissions_policy: None,
csp: None,
csp_report_only: false,
custom: BTreeMap::new(),
}
}
#[must_use]
pub fn dev() -> Self {
Self {
hsts: None,
xfo: None,
nosniff: true,
referrer_policy: None,
coop: None,
permissions_policy: None,
csp: None,
csp_report_only: false,
custom: BTreeMap::new(),
}
}
#[must_use]
pub fn hsts(mut self, value: impl Into<String>) -> Self {
self.hsts = Some(value.into());
self
}
#[must_use]
pub fn xfo(mut self, value: &'static str) -> Self {
self.xfo = Some(value);
self
}
#[must_use]
pub fn csp(mut self, csp: String) -> Self {
self.csp = Some(csp);
self
}
#[must_use]
pub fn csp_report_only(mut self, yes: bool) -> Self {
self.csp_report_only = yes;
self
}
#[must_use]
pub fn csp_report_uri(mut self, uri: &str) -> Self {
if let Some(existing) = self.csp.as_mut() {
existing.push_str(&format!("; report-uri {uri}"));
} else {
self.csp = Some(format!("default-src 'self'; report-uri {uri}"));
}
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.insert(name.into(), value.into());
self
}
#[must_use]
pub fn from_preset(name: &str) -> Self {
match name {
"strict" => Self::strict(),
"relaxed" => Self::relaxed(),
"dev" => Self::dev(),
"none" | "empty" => Self::empty(),
_ => Self::strict(),
}
}
#[cfg(feature = "config")]
#[must_use]
pub fn from_settings(s: &crate::config::SecuritySettings) -> Self {
let mut layer = match s.headers_preset.as_deref() {
Some(name) => Self::from_preset(name),
None => Self::strict(),
};
if let Some(csp) = s.csp.as_ref() {
layer.csp = Some(csp.clone());
}
if let Some(secs) = s.hsts_max_age_secs {
if adds_hsts_directives(layer.hsts.as_deref(), secs) {
tracing::warn!(
target: "rustango::security_headers",
"[security] hsts_max_age_secs adds `includeSubDomains; preload` to preset {:?}, \
which sends neither; 0.62.0 will keep the preset's directives",
s.headers_preset.as_deref().unwrap_or("strict"),
);
}
layer.hsts = if secs == 0 {
None
} else {
Some(format!("max-age={secs}; includeSubDomains; preload"))
};
}
layer
}
}
#[cfg(feature = "config")]
fn adds_hsts_directives(preset: Option<&str>, secs: u64) -> bool {
secs != 0 && !preset.is_some_and(|v| v.split(';').any(|p| p.trim() == "preload"))
}
pub trait SecurityHeadersRouterExt {
#[must_use]
fn security_headers(self, layer: SecurityHeadersLayer) -> Self;
}
impl<S: Clone + Send + Sync + 'static> SecurityHeadersRouterExt for Router<S> {
fn security_headers(self, layer: SecurityHeadersLayer) -> Self {
let cfg = Arc::new(layer);
self.layer(axum::middleware::from_fn(
move |req: Request<Body>, next: Next| {
let cfg = cfg.clone();
async move { handle(cfg, req, next).await }
},
))
}
}
async fn handle(cfg: Arc<SecurityHeadersLayer>, req: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(req).await;
let mut own = axum::http::HeaderMap::new();
let mut set = |name: &str, value: &str| {
if let (Ok(n), Ok(v)) = (HeaderName::try_from(name), HeaderValue::from_str(value)) {
own.insert(n, v);
}
};
if let Some(v) = &cfg.hsts {
set("strict-transport-security", v);
}
if let Some(v) = cfg.xfo {
set("x-frame-options", v);
}
if cfg.nosniff {
set("x-content-type-options", "nosniff");
}
if let Some(v) = cfg.referrer_policy {
set("referrer-policy", v);
}
if let Some(v) = cfg.coop {
set("cross-origin-opener-policy", v);
}
if let Some(v) = &cfg.permissions_policy {
set("permissions-policy", v);
}
if let Some(v) = &cfg.csp {
let name = if cfg.csp_report_only {
"content-security-policy-report-only"
} else {
"content-security-policy"
};
set(name, v);
}
for (k, v) in &cfg.custom {
set(k, v);
}
let headers = response.headers_mut();
for (name, value) in own {
if let Some(name) = name {
headers.entry(name).or_insert(value);
}
}
response
}
pub fn csp_report_router(path: &str) -> axum::Router {
use axum::routing::post;
let path = path.to_owned();
axum::Router::new().route(
&path,
post(handle_csp_report).layer(axum::extract::DefaultBodyLimit::max(CSP_REPORT_MAX_BYTES)),
)
}
const CSP_REPORT_MAX_BYTES: usize = 64 * 1024;
async fn handle_csp_report(body: axum::body::Bytes) -> axum::http::StatusCode {
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&body) else {
return axum::http::StatusCode::BAD_REQUEST;
};
match value {
serde_json::Value::Array(reports) => {
let mut violations = reports.iter().filter(|r| r["type"] == "csp-violation");
for r in violations.by_ref().take(CSP_REPORTS_LOGGED) {
let b = &r["body"];
log_csp_violation(
str_at(b, "documentURL"),
str_at(b, "effectiveDirective"),
str_at(b, "blockedURL"),
);
}
let skipped = violations.count();
if skipped > 0 {
tracing::warn!(skipped, "CSP violation reports not logged");
}
}
v => {
let report = v.get("csp-report").unwrap_or(&v);
log_csp_violation(
str_at(report, "document-uri"),
str_at(report, "violated-directive"),
str_at(report, "blocked-uri"),
);
}
}
axum::http::StatusCode::NO_CONTENT
}
fn str_at<'a>(v: &'a serde_json::Value, key: &str) -> &'a str {
v.get(key).and_then(|v| v.as_str()).unwrap_or("?")
}
const CSP_REPORTS_LOGGED: usize = 10;
fn log_csp_violation(document_uri: &str, violated: &str, blocked: &str) {
let no_query = |u: &'_ str| u.split(['?', '#']).next().unwrap_or_default().to_owned();
tracing::warn!(
document_uri = ?no_query(document_uri),
violated_directive = ?violated,
blocked_uri = ?no_query(blocked),
"CSP violation report",
);
}
#[derive(Debug, Clone, Default)]
pub struct CspBuilder {
directives: BTreeMap<String, Vec<String>>,
}
impl CspBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn set(&mut self, name: &str, sources: &[&str]) {
self.directives.insert(
name.to_owned(),
sources.iter().map(|s| (*s).to_owned()).collect(),
);
}
#[must_use]
pub fn default_src(mut self, sources: &[&str]) -> Self {
self.set("default-src", sources);
self
}
#[must_use]
pub fn script_src(mut self, sources: &[&str]) -> Self {
self.set("script-src", sources);
self
}
#[must_use]
pub fn style_src(mut self, sources: &[&str]) -> Self {
self.set("style-src", sources);
self
}
#[must_use]
pub fn img_src(mut self, sources: &[&str]) -> Self {
self.set("img-src", sources);
self
}
#[must_use]
pub fn font_src(mut self, sources: &[&str]) -> Self {
self.set("font-src", sources);
self
}
#[must_use]
pub fn connect_src(mut self, sources: &[&str]) -> Self {
self.set("connect-src", sources);
self
}
#[must_use]
pub fn frame_src(mut self, sources: &[&str]) -> Self {
self.set("frame-src", sources);
self
}
#[must_use]
pub fn frame_ancestors(mut self, sources: &[&str]) -> Self {
self.set("frame-ancestors", sources);
self
}
#[must_use]
pub fn object_src(mut self, sources: &[&str]) -> Self {
self.set("object-src", sources);
self
}
#[must_use]
pub fn directive(mut self, name: impl Into<String>, sources: &[&str]) -> Self {
let name = name.into();
self.directives
.insert(name, sources.iter().map(|s| (*s).to_owned()).collect());
self
}
#[must_use]
pub fn strict_starter() -> Self {
Self::new()
.default_src(&["'self'"])
.object_src(&["'none'"])
.directive("base-uri", &["'self'"])
}
#[must_use]
pub fn build(&self) -> String {
self.directives
.iter()
.map(|(k, v)| format!("{k} {}", v.join(" ")))
.collect::<Vec<_>>()
.join("; ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_preset_has_all_canonical_headers() {
let l = SecurityHeadersLayer::strict();
assert!(l.hsts.is_some());
assert_eq!(l.xfo, Some("DENY"));
assert!(l.nosniff);
assert_eq!(l.referrer_policy, Some("same-origin"));
assert_eq!(l.coop, Some("same-origin"));
assert!(l.permissions_policy.is_some());
}
#[tokio::test]
async fn a_header_the_handler_set_wins() {
use axum::routing::get;
use tower::ServiceExt as _;
let app = Router::new()
.route(
"/",
get(|| async { ([("referrer-policy", "no-referrer")], "ok") }),
)
.security_headers(SecurityHeadersLayer::strict());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers()["referrer-policy"], "no-referrer");
assert_eq!(resp.headers()["x-frame-options"], "DENY");
}
#[tokio::test]
async fn a_custom_header_overrides_the_preset() {
use axum::routing::get;
use tower::ServiceExt as _;
let app = Router::new()
.route("/", get(|| async { "ok" }))
.security_headers(
SecurityHeadersLayer::strict().header("x-frame-options", "SAMEORIGIN"),
);
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers()["x-frame-options"], "SAMEORIGIN");
}
#[test]
fn no_preset_makes_browsers_send_a_null_origin() {
for l in [
SecurityHeadersLayer::strict(),
SecurityHeadersLayer::relaxed(),
SecurityHeadersLayer::dev(),
] {
assert_ne!(l.referrer_policy, Some("no-referrer"));
}
}
#[test]
fn relaxed_preset_allows_same_origin_framing() {
let l = SecurityHeadersLayer::relaxed();
assert_eq!(l.xfo, Some("SAMEORIGIN"));
assert!(l.hsts.is_some());
assert!(l.coop.is_none());
}
#[test]
fn dev_preset_only_nosniff() {
let l = SecurityHeadersLayer::dev();
assert!(
l.hsts.is_none(),
"dev must NOT set HSTS — would lock localhost to https"
);
assert!(l.xfo.is_none());
assert!(l.nosniff);
}
#[test]
fn empty_preset_sets_nothing() {
let l = SecurityHeadersLayer::empty();
assert!(l.hsts.is_none());
assert!(!l.nosniff);
assert!(l.csp.is_none());
}
#[test]
fn custom_header_chained_in() {
let l = SecurityHeadersLayer::strict().header("x-custom", "value");
assert_eq!(l.custom.get("x-custom").map(String::as_str), Some("value"));
}
#[test]
fn csp_builder_basic() {
let csp = CspBuilder::new().default_src(&["'self'"]).build();
assert_eq!(csp, "default-src 'self'");
}
#[test]
fn csp_builder_multi_source() {
let csp = CspBuilder::new()
.script_src(&["'self'", "https://cdn.example.com"])
.build();
assert_eq!(csp, "script-src 'self' https://cdn.example.com");
}
#[test]
fn csp_builder_multiple_directives_joined_by_semicolon() {
let csp = CspBuilder::new()
.default_src(&["'self'"])
.img_src(&["'self'", "data:"])
.build();
assert_eq!(csp, "default-src 'self'; img-src 'self' data:");
}
#[test]
fn csp_builder_strict_starter_preset() {
let csp = CspBuilder::strict_starter().build();
assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("object-src 'none'"));
assert!(csp.contains("base-uri 'self'"));
}
#[test]
fn csp_builder_directive_helper() {
let csp = CspBuilder::new()
.directive("upgrade-insecure-requests", &[])
.build();
assert!(csp.contains("upgrade-insecure-requests"));
}
#[test]
fn csp_attached_to_layer() {
let csp = CspBuilder::new().default_src(&["'self'"]).build();
let l = SecurityHeadersLayer::strict().csp(csp.clone());
assert_eq!(l.csp.as_deref(), Some(csp.as_str()));
}
#[test]
fn report_only_flag_toggles() {
let l = SecurityHeadersLayer::strict()
.csp("default-src 'self'".into())
.csp_report_only(true);
assert!(l.csp_report_only);
}
#[test]
fn csp_report_uri_appends_to_existing_csp() {
let l = SecurityHeadersLayer::strict()
.csp("default-src 'self'".into())
.csp_report_uri("/__csp-report");
let csp = l.csp.unwrap();
assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("report-uri /__csp-report"));
}
#[test]
fn csp_report_uri_creates_default_csp_if_missing() {
let l = SecurityHeadersLayer::strict().csp_report_uri("/__csp-report");
let csp = l.csp.unwrap();
assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("report-uri"));
}
#[test]
fn from_preset_maps_known_names() {
assert_eq!(
SecurityHeadersLayer::from_preset("strict").hsts.as_deref(),
SecurityHeadersLayer::strict().hsts.as_deref(),
);
assert!(SecurityHeadersLayer::from_preset("dev").hsts.is_none());
assert!(SecurityHeadersLayer::from_preset("none").hsts.is_none());
assert!(!SecurityHeadersLayer::from_preset("none").nosniff);
}
#[test]
fn from_preset_unknown_name_fails_safe_to_strict() {
let l = SecurityHeadersLayer::from_preset("strixt"); assert_eq!(
l.hsts.as_deref(),
SecurityHeadersLayer::strict().hsts.as_deref(),
"unknown preset should yield strict, not empty"
);
}
#[cfg(feature = "config")]
#[test]
fn from_settings_default_picks_strict() {
let s = crate::config::SecuritySettings::default();
let l = SecurityHeadersLayer::from_settings(&s);
assert_eq!(
l.hsts.as_deref(),
SecurityHeadersLayer::strict().hsts.as_deref(),
);
}
#[cfg(feature = "config")]
#[test]
fn from_settings_honors_preset_name() {
let mut s = crate::config::SecuritySettings::default();
s.headers_preset = Some("dev".into());
let l = SecurityHeadersLayer::from_settings(&s);
assert!(l.hsts.is_none());
}
#[cfg(feature = "config")]
#[test]
fn from_settings_zero_hsts_disables_header() {
let mut s = crate::config::SecuritySettings::default();
s.headers_preset = Some("strict".into());
s.hsts_max_age_secs = Some(0);
let l = SecurityHeadersLayer::from_settings(&s);
assert!(l.hsts.is_none(), "hsts_max_age_secs = 0 should drop HSTS");
}
#[cfg(feature = "config")]
#[test]
fn from_settings_custom_hsts_max_age() {
let mut s = crate::config::SecuritySettings::default();
s.hsts_max_age_secs = Some(60);
let l = SecurityHeadersLayer::from_settings(&s);
let hsts = l.hsts.expect("hsts present");
assert!(hsts.contains("max-age=60"), "got: {hsts}");
assert!(
hsts.contains("includeSubDomains"),
"preserved subdomain inclusion"
);
}
#[cfg(feature = "config")]
#[test]
fn a_max_age_on_a_preset_without_preload_warns() {
let relaxed = SecurityHeadersLayer::relaxed();
let strict = SecurityHeadersLayer::strict();
assert!(adds_hsts_directives(relaxed.hsts.as_deref(), 60));
assert!(adds_hsts_directives(None, 60), "dev / none send no HSTS");
assert!(!adds_hsts_directives(strict.hsts.as_deref(), 60));
assert!(!adds_hsts_directives(relaxed.hsts.as_deref(), 0));
let mut s = crate::config::SecuritySettings::default();
s.headers_preset = Some("relaxed".into());
s.hsts_max_age_secs = Some(60);
assert_eq!(
SecurityHeadersLayer::from_settings(&s).hsts.as_deref(),
Some("max-age=60; includeSubDomains; preload"),
"the output itself is unchanged in 0.61"
);
}
#[cfg(feature = "config")]
#[test]
fn from_settings_custom_csp_overrides_preset() {
let mut s = crate::config::SecuritySettings::default();
s.csp = Some("default-src 'self'; img-src *".into());
let l = SecurityHeadersLayer::from_settings(&s);
assert_eq!(l.csp.as_deref(), Some("default-src 'self'; img-src *"));
}
}
#[cfg(all(test, feature = "csrf"))]
mod csp_report_tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
async fn post(content_type: &str, origin: Option<&str>, body: String) -> u16 {
let app = super::csp_report_router("/__csp-report").layer(crate::forms::csrf::layer());
let mut req = Request::post("/__csp-report")
.header("host", "app.test")
.header("content-type", content_type);
if let Some(o) = origin {
req = req.header("origin", o);
}
let resp = app
.oneshot(req.body(Body::from(body)).unwrap())
.await
.unwrap();
resp.status().as_u16()
}
#[tokio::test]
async fn browser_report_types_are_accepted_without_a_token() {
let legacy = r#"{"csp-report":{"document-uri":"http://app.test/","violated-directive":"script-src","blocked-uri":"inline"}}"#;
let api = r#"[{"type":"csp-violation","body":{"documentURL":"http://app.test/","effectiveDirective":"script-src","blockedURL":"inline"}}]"#;
let origin = Some("http://app.test");
assert_eq!(
post("application/csp-report", origin, legacy.into()).await,
204
);
assert_eq!(
post("application/reports+json", origin, api.into()).await,
204
);
}
#[tokio::test]
async fn report_types_still_need_a_same_origin_and_a_small_body() {
let legacy = r#"{"csp-report":{}}"#;
let evil = Some("http://evil.example");
assert_eq!(
post("application/csp-report", evil, legacy.into()).await,
403
);
let big = format!(r#"{{"csp-report":{{"x":"{}"}}}}"#, "a".repeat(100 * 1024));
assert_eq!(post("application/csp-report", None, big).await, 413);
assert_eq!(
post("text/plain", None, legacy.into()).await,
403,
"other types need the token"
);
}
#[tokio::test]
async fn report_types_need_the_token_on_other_routes() {
let api = axum::Router::new()
.route(
"/api/items",
axum::routing::post(|b: String| async move { b }),
)
.merge(super::csp_report_router("/__csp-report"))
.layer(crate::forms::csrf::layer())
.layer(crate::forms::csrf::layer());
for ct in ["application/reports+json", "application/csp-report"] {
let req = Request::post("/api/items")
.header("host", "app.test")
.header("origin", "http://app.test")
.header("content-type", ct)
.body(Body::from("[]"))
.unwrap();
let resp = api.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status().as_u16(), 403, "{ct}");
}
}
#[cfg(feature = "runtime")]
#[test]
fn a_report_batch_logs_a_bounded_number_of_lines() {
let buf = crate::testkit::CaptureWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let one = r#"{"type":"csp-violation","body":{"documentURL":"a\nFAKE b"}}"#;
let body = format!("[{}]", vec![one; 500].join(","));
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let status = tracing::subscriber::with_default(subscriber, || {
rt.block_on(super::handle_csp_report(body.into()))
});
assert_eq!(status, 204);
let out = buf.contents();
assert_eq!(out.lines().count(), 10 + 1, "{out}");
assert!(!out.lines().any(|l| l.starts_with("FAKE")), "{out}");
assert!(out.contains("skipped=490"), "{out}");
}
#[cfg(feature = "runtime")]
#[test]
fn a_report_logs_urls_without_query_or_fragment() {
let buf = crate::testkit::CaptureWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
let body = r#"{"csp-report":{"document-uri":"https://app.test/reset?token=SECRET1#x","violated-directive":"img-src","blocked-uri":"https://app.test/cb?code=SECRET2"}}"#;
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let status = tracing::subscriber::with_default(subscriber, || {
rt.block_on(super::handle_csp_report(body.into()))
});
assert_eq!(status, 204);
let out = buf.contents();
assert!(out.contains("https://app.test/reset"), "{out}");
assert!(!out.contains("SECRET"), "{out}");
}
#[tokio::test]
async fn https_report_without_origin_is_accepted() {
let send = |origin: Option<&'static str>| async move {
let app = super::csp_report_router("/__csp-report").layer(crate::forms::csrf::layer());
let mut req = Request::post("/__csp-report")
.header("host", "app.test")
.header("x-forwarded-proto", "https")
.header("content-type", "application/csp-report");
if let Some(o) = origin {
req = req.header("origin", o);
}
let body = Body::from(r#"{"csp-report":{}}"#);
app.oneshot(req.body(body).unwrap())
.await
.unwrap()
.status()
.as_u16()
};
assert_eq!(send(None).await, 204);
assert_eq!(send(Some("https://app.test")).await, 204);
assert_eq!(send(Some("https://evil.example")).await, 403);
}
}