//! Explicit per-vhost response protection. HSTS is never emitted on plaintext. use anyhow::{ensure, Result}; use http::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SecurityHeaders { pub hsts: Hsts, pub nosniff: bool, pub frame_options: FrameOptions, pub referrer_policy: Option, pub content_security_policy: Option, pub permissions_policy: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Hsts { pub enabled: bool, pub max_age_seconds: u64, pub include_subdomains: bool, pub preload: bool, } impl Default for Hsts { fn default() -> Self { Self { enabled: false, max_age_seconds: 31_536_000, include_subdomains: false, preload: false, } } } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FrameOptions { #[default] Preserve, Deny, Sameorigin, } #[derive(Debug, Clone, Default)] pub struct CompiledSecurity { hsts: Option, headers: Vec<(&'static str, HeaderValue)>, } impl SecurityHeaders { pub fn compile(&self) -> Result { ensure!( self.hsts.max_age_seconds <= 63_072_000, "HSTS max_age_seconds must be 0..63072000" ); ensure!( !self.hsts.preload || (self.hsts.enabled && self.hsts.include_subdomains && self.hsts.max_age_seconds >= 31_536_000), "HSTS preload requires enabled, include_subdomains and at least one year" ); let mut result = CompiledSecurity::default(); if self.hsts.enabled { let mut value = format!("max-age={}", self.hsts.max_age_seconds); if self.hsts.include_subdomains { value.push_str("; includeSubDomains"); } if self.hsts.preload { value.push_str("; preload"); } result.hsts = Some(value.parse()?); } if self.nosniff { result.headers.push(( "x-content-type-options", HeaderValue::from_static("nosniff"), )); } let frame = match self.frame_options { FrameOptions::Preserve => None, FrameOptions::Deny => Some("DENY"), FrameOptions::Sameorigin => Some("SAMEORIGIN"), }; if let Some(value) = frame { result .headers .push(("x-frame-options", HeaderValue::from_static(value))); } for (name, value) in [ ("referrer-policy", &self.referrer_policy), ("content-security-policy", &self.content_security_policy), ("permissions-policy", &self.permissions_policy), ] { if let Some(value) = value { ensure!( !value.is_empty() && value.len() <= 8192, "security header must contain 1..8192 bytes" ); result.headers.push((name, value.parse()?)); } } Ok(result) } } impl CompiledSecurity { pub fn apply(&self, headers: &mut HeaderMap, tls: bool) { for (name, value) in &self.headers { headers.insert(*name, value.clone()); } if !tls { headers.remove("strict-transport-security"); } else if let Some(value) = &self.hsts { headers.insert("strict-transport-security", value.clone()); } } } #[cfg(test)] mod tests { use super::*; #[test] fn hsts_is_tls_only_and_preload_is_explicit() { let mut s = SecurityHeaders::default(); s.hsts.enabled = true; let c = s.compile().unwrap(); let mut h = HeaderMap::new(); c.apply(&mut h, true); assert_eq!(h["strict-transport-security"], "max-age=31536000"); c.apply(&mut h, false); assert!(!h.contains_key("strict-transport-security")); s.hsts.preload = true; assert!(s.compile().is_err()); s.hsts.include_subdomains = true; assert!(s.compile().is_ok()); s.hsts.max_age_seconds = 0; assert!(s.compile().is_err()); s.hsts.preload = false; assert!(s.compile().is_ok()); } #[test] fn rejects_response_splitting() { let s = SecurityHeaders { content_security_policy: Some("x\r\ninjected: yes".into()), ..Default::default() }; assert!(s.compile().is_err()); } }