This commit is contained in:
gru committed 2026-10-03 00:27:09 +01:00
commit 5e23e9af02
240 files changed
+99957

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
//! 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<String>,
pub content_security_policy: Option<String>,
pub permissions_policy: Option<String>,
}
#[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<HeaderValue>,
headers: Vec<(&'static str, HeaderValue)>,
}
impl SecurityHeaders {
pub fn compile(&self) -> Result<CompiledSecurity> {
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());
}
}