use axum::{
extract::Request,
http::{HeaderValue, Response},
middleware::Next,
};
use std::sync::OnceLock;
use tracing::warn;
static IS_DEVELOPMENT: OnceLock<bool> = OnceLock::new();
fn is_development_mode() -> bool {
*IS_DEVELOPMENT.get_or_init(|| {
let is_dev = std::env::var("ENVIRONMENT")
.map(|v| v.to_lowercase() == "development")
.unwrap_or(false);
if is_dev {
warn!(
"Running with ENVIRONMENT=development - using permissive CSP. \
Set ENVIRONMENT=production for strict security headers."
);
}
is_dev
})
}
pub async fn security_headers_middleware(
request: Request,
next: Next,
) -> Response<axum::body::Body> {
let mut response = next.run(request).await;
let headers = response.headers_mut();
let csp = if is_development_mode() {
"default-src 'self'; \
script-src 'self' 'unsafe-inline' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline'; \
connect-src 'self' http://localhost:* ws://localhost:* wss://localhost:* https://*.sentry.io; \
img-src 'self' data: https:; \
font-src 'self' data: https:; \
frame-ancestors 'self'"
} else {
"default-src 'none'; \
script-src 'self' https://js.sentry-cdn.com; \
style-src 'self' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data: https:; \
connect-src 'self' https://*.sentry.io https://api.postmarkapp.com https://api.brevo.com; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'; \
upgrade-insecure-requests"
};
headers.insert(
"content-security-policy",
HeaderValue::from_str(csp).unwrap_or_default(),
);
if !is_development_mode() {
headers.insert(
"strict-transport-security",
HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"),
);
}
headers.insert(
"x-frame-options",
HeaderValue::from_static("DENY"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
headers.insert(
"permissions-policy",
HeaderValue::from_static(
"geolocation=(), \
microphone=(), \
camera=(), \
payment=(), \
usb=(), \
magnetometer=(), \
gyroscope=(), \
accelerometer=()",
),
);
headers.insert(
"x-xss-protection",
HeaderValue::from_static("1; mode=block"),
);
response
}
pub fn security_headers_layer() -> impl tower::Layer<axum::Router> + Clone {
use tower::Layer;
use tower_http::set_header::SetResponseHeader;
let csp_layer = SetResponseHeader::overriding(
"content-security-policy",
HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; base-uri 'self'",
),
);
let frame_options_layer = SetResponseHeader::overriding(
"x-frame-options",
HeaderValue::from_static("DENY"),
);
let content_type_layer = SetResponseHeader::overriding(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
let referrer_layer = SetResponseHeader::overriding(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
csp_layer
.and_then(frame_options_layer)
.and_then(content_type_layer)
.and_then(referrer_layer)
}