use serde::Deserialize;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub enum FrameOptions {
Deny,
SameOrigin,
AllowFrom(String),
}
impl Default for FrameOptions {
fn default() -> Self {
Self::Deny
}
}
impl fmt::Display for FrameOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Deny => write!(f, "DENY"),
Self::SameOrigin => write!(f, "SAMEORIGIN"),
Self::AllowFrom(uri) => write!(f, "ALLOW-FROM {uri}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct HstsConfig {
#[serde(default = "default_hsts_max_age")]
pub max_age: u64,
#[serde(default = "default_true")]
pub include_subdomains: bool,
#[serde(default)]
pub preload: bool,
}
fn default_hsts_max_age() -> u64 {
31536000
}
fn default_true() -> bool {
true
}
impl Default for HstsConfig {
fn default() -> Self {
Self {
max_age: 31536000,
include_subdomains: true,
preload: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub enum ReferrerPolicy {
NoReferrer,
NoReferrerWhenDowngrade,
SameOrigin,
Origin,
StrictOrigin,
OriginWhenCrossOrigin,
StrictOriginWhenCrossOrigin,
UnsafeUrl,
}
impl Default for ReferrerPolicy {
fn default() -> Self {
Self::NoReferrer
}
}
impl fmt::Display for ReferrerPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoReferrer => write!(f, "no-referrer"),
Self::NoReferrerWhenDowngrade => write!(f, "no-referrer-when-downgrade"),
Self::SameOrigin => write!(f, "same-origin"),
Self::Origin => write!(f, "origin"),
Self::StrictOrigin => write!(f, "strict-origin"),
Self::OriginWhenCrossOrigin => write!(f, "origin-when-cross-origin"),
Self::StrictOriginWhenCrossOrigin => write!(f, "strict-origin-when-cross-origin"),
Self::UnsafeUrl => write!(f, "unsafe-url"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SecurityHeadersConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub frame_options: FrameOptions,
#[serde(default = "default_true")]
pub content_type_options: bool,
#[serde(default)]
pub hsts: HstsConfig,
#[serde(default)]
pub csp: Option<String>,
#[serde(default)]
pub referrer_policy: ReferrerPolicy,
#[serde(default)]
pub permissions_policy: Option<String>,
}
impl Default for SecurityHeadersConfig {
fn default() -> Self {
Self {
enabled: true,
frame_options: FrameOptions::Deny,
content_type_options: true,
hsts: HstsConfig::default(),
csp: None,
referrer_policy: ReferrerPolicy::NoReferrer,
permissions_policy: None,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecurityHeadersError {
#[error("CSP nonce 生成失败: {0}")]
NonceGenerationFailed(#[from] rand::Error),
}
use axum::extract::Request;
use axum::http::HeaderName;
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;
pub fn generate_csp_nonce() -> Result<String, SecurityHeadersError> {
use rand::RngCore;
let mut bytes = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut bytes);
use base64::Engine;
let nonce = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&bytes);
Ok(nonce)
}
pub fn inject_security_headers(
response: &mut Response,
config: &SecurityHeadersConfig,
is_https: bool,
) -> Result<(), SecurityHeadersError> {
let headers = response.headers_mut();
if !headers.contains_key("x-frame-options") {
if let Ok(val) = HeaderValue::from_str(&config.frame_options.to_string()) {
headers.insert(HeaderName::from_static("x-frame-options"), val);
}
}
if config.content_type_options && !headers.contains_key("x-content-type-options") {
headers.insert(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
);
}
if is_https && config.hsts.max_age > 0 && !headers.contains_key("strict-transport-security") {
let mut hsts_val = format!("max-age={}", config.hsts.max_age);
if config.hsts.include_subdomains {
hsts_val.push_str("; includeSubDomains");
}
if config.hsts.preload {
hsts_val.push_str("; preload");
}
if let Ok(val) = HeaderValue::from_str(&hsts_val) {
headers.insert(HeaderName::from_static("strict-transport-security"), val);
}
}
if let Some(csp_template) = &config.csp {
if !headers.contains_key("content-security-policy") {
let csp_value = if csp_template.contains("{nonce}") {
match generate_csp_nonce() {
Ok(nonce) => csp_template.replace("{nonce}", &nonce),
Err(e) => {
tracing::error!("CSP nonce 生成失败,跳过 CSP 注入: {e}");
return Ok(());
}
}
} else {
csp_template.clone()
};
if let Ok(val) = HeaderValue::from_str(&csp_value) {
headers.insert(HeaderName::from_static("content-security-policy"), val);
}
}
}
if !headers.contains_key("referrer-policy") {
if let Ok(val) = HeaderValue::from_str(&config.referrer_policy.to_string()) {
headers.insert(HeaderName::from_static("referrer-policy"), val);
}
}
if let Some(pp) = &config.permissions_policy {
if !headers.contains_key("permissions-policy") {
if let Ok(val) = HeaderValue::from_str(pp) {
headers.insert(HeaderName::from_static("permissions-policy"), val);
}
}
}
Ok(())
}
pub async fn security_headers_middleware(
axum::extract::State(config): axum::extract::State<SecurityHeadersConfig>,
req: Request,
next: Next,
) -> Response {
if !config.enabled {
return next.run(req).await;
}
let is_https = req
.uri()
.scheme()
.map(|s| s == &axum::http::uri::Scheme::HTTPS)
.unwrap_or(false);
let mut response = next.run(req).await;
if let Err(e) = inject_security_headers(&mut response, &config, is_https) {
tracing::error!("安全响应头注入失败(fail-open): {e}");
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_is_secure() {
let cfg = SecurityHeadersConfig::default();
assert!(cfg.enabled, "默认应启用(spec §4.3.1 默认安全)");
assert_eq!(cfg.frame_options, FrameOptions::Deny);
assert!(cfg.content_type_options);
assert_eq!(cfg.hsts.max_age, 31536000);
assert!(cfg.hsts.include_subdomains);
assert!(!cfg.hsts.preload);
assert!(cfg.csp.is_none());
assert_eq!(cfg.referrer_policy, ReferrerPolicy::NoReferrer);
assert!(cfg.permissions_policy.is_none());
}
#[test]
fn test_frame_options_display() {
assert_eq!(FrameOptions::Deny.to_string(), "DENY");
assert_eq!(FrameOptions::SameOrigin.to_string(), "SAMEORIGIN");
assert_eq!(
FrameOptions::AllowFrom("https://example.com".to_string()).to_string(),
"ALLOW-FROM https://example.com"
);
}
#[test]
fn test_referrer_policy_display() {
assert_eq!(ReferrerPolicy::NoReferrer.to_string(), "no-referrer");
assert_eq!(
ReferrerPolicy::NoReferrerWhenDowngrade.to_string(),
"no-referrer-when-downgrade"
);
assert_eq!(ReferrerPolicy::SameOrigin.to_string(), "same-origin");
assert_eq!(ReferrerPolicy::Origin.to_string(), "origin");
assert_eq!(ReferrerPolicy::StrictOrigin.to_string(), "strict-origin");
assert_eq!(
ReferrerPolicy::OriginWhenCrossOrigin.to_string(),
"origin-when-cross-origin"
);
assert_eq!(
ReferrerPolicy::StrictOriginWhenCrossOrigin.to_string(),
"strict-origin-when-cross-origin"
);
assert_eq!(ReferrerPolicy::UnsafeUrl.to_string(), "unsafe-url");
}
#[test]
fn test_hsts_default() {
let hsts = HstsConfig::default();
assert_eq!(hsts.max_age, 31536000);
assert!(hsts.include_subdomains);
assert!(!hsts.preload);
}
#[test]
fn test_generate_csp_nonce_unique() {
let mut nonces = std::collections::HashSet::new();
for _ in 0..100 {
let nonce = generate_csp_nonce().unwrap();
assert_eq!(nonce.len(), 22, "Base64 编码 16 字节应为 22 字符");
nonces.insert(nonce);
}
assert_eq!(nonces.len(), 100, "100 个 nonce 应全部不同");
}
#[test]
fn test_inject_default_headers_http() {
let config = SecurityHeadersConfig::default();
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, false).unwrap();
let headers = response.headers();
assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
assert!(headers.get("strict-transport-security").is_none(), "HTTP 不注入 HSTS");
assert_eq!(headers.get("referrer-policy").unwrap(), "no-referrer");
}
#[test]
fn test_inject_hsts_https() {
let config = SecurityHeadersConfig::default();
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, true).unwrap();
let hsts = response.headers().get("strict-transport-security").unwrap();
let hsts_str = hsts.to_str().unwrap();
assert!(hsts_str.contains("max-age=31536000"));
assert!(hsts_str.contains("includeSubDomains"));
}
#[test]
fn test_inject_hsts_preload() {
let mut config = SecurityHeadersConfig::default();
config.hsts.preload = true;
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, true).unwrap();
let hsts = response.headers().get("strict-transport-security").unwrap();
assert!(hsts.to_str().unwrap().contains("preload"));
}
#[test]
fn test_inject_csp_with_nonce() {
let mut config = SecurityHeadersConfig::default();
config.csp = Some("default-src 'self'; script-src 'self' 'nonce-{nonce}'".to_string());
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, false).unwrap();
let csp = response.headers().get("content-security-policy").unwrap();
let csp_str = csp.to_str().unwrap();
assert!(csp_str.contains("'nonce-"));
assert!(!csp_str.contains("{nonce}"), "占位符应被替换");
}
#[test]
fn test_inject_csp_without_nonce() {
let mut config = SecurityHeadersConfig::default();
config.csp = Some("default-src 'self'".to_string());
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, false).unwrap();
let csp = response.headers().get("content-security-policy").unwrap();
assert_eq!(csp.to_str().unwrap(), "default-src 'self'");
}
#[test]
fn test_inject_permissions_policy() {
let mut config = SecurityHeadersConfig::default();
config.permissions_policy = Some("geolocation=(), camera=()".to_string());
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, false).unwrap();
let pp = response.headers().get("permissions-policy").unwrap();
assert_eq!(pp.to_str().unwrap(), "geolocation=(), camera=()");
}
#[test]
fn test_downstream_headers_not_overwritten() {
let config = SecurityHeadersConfig::default();
let mut response = Response::new(axum::body::Body::empty());
response.headers_mut().insert(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("ALLOWALL"),
);
inject_security_headers(&mut response, &config, false).unwrap();
assert_eq!(
response.headers().get("x-frame-options").unwrap(),
"ALLOWALL",
"下游设置的头部不应被覆盖"
);
}
#[test]
fn test_hsts_max_age_zero_skipped() {
let mut config = SecurityHeadersConfig::default();
config.hsts.max_age = 0;
let mut response = Response::new(axum::body::Body::empty());
inject_security_headers(&mut response, &config, true).unwrap();
assert!(response.headers().get("strict-transport-security").is_none());
}
#[tokio::test]
async fn test_middleware_disabled_passes_through() {
use axum::routing::get;
use tower::ServiceExt;
let mut config = SecurityHeadersConfig::default();
config.enabled = false;
let app = axum::Router::new()
.route("/", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
config,
security_headers_middleware,
));
let resp = app.oneshot(
axum::http::Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
).await.unwrap();
assert!(resp.headers().get("x-frame-options").is_none(), "disabled 不注入");
}
#[tokio::test]
async fn test_middleware_enabled_injects_headers() {
use axum::routing::get;
use tower::ServiceExt;
let config = SecurityHeadersConfig::default();
let app = axum::Router::new()
.route("/", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
config,
security_headers_middleware,
));
let resp = app.oneshot(
axum::http::Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
).await.unwrap();
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(resp.headers().get("referrer-policy").unwrap(), "no-referrer");
}
}