146 lines
4.6 KiB
Rust
146 lines
4.6 KiB
Rust
//! 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());
|
|
}
|
|
}
|