use std::sync::OnceLock;
use hyper::Response;
use hyper::header::{HeaderMap, HeaderName, HeaderValue};
use crate::body::ProxyBody;
use orca_core::config::SecurityHeadersConfig;
static POLICY: OnceLock<Policy> = OnceLock::new();
struct Policy {
always: Vec<(HeaderName, HeaderValue)>,
https_only: Vec<(HeaderName, HeaderValue)>,
}
pub fn init(config: Option<SecurityHeadersConfig>) {
let _ = POLICY.set(build(config.unwrap_or_default()));
}
fn build(cfg: SecurityHeadersConfig) -> Policy {
if !cfg.enabled {
return Policy {
always: Vec::new(),
https_only: Vec::new(),
};
}
let mut always = Vec::new();
let mut https_only = Vec::new();
push(
&mut always,
"x-content-type-options",
&cfg.content_type_options,
);
push(&mut always, "referrer-policy", &cfg.referrer_policy);
push(&mut always, "x-frame-options", &cfg.frame_options);
push(&mut always, "content-security-policy", &cfg.csp);
push(&mut https_only, "strict-transport-security", &cfg.hsts);
for (k, v) in &cfg.extra {
if let (Ok(name), Ok(val)) = (
HeaderName::try_from(k.as_str()),
HeaderValue::try_from(v.as_str()),
) {
always.push((name, val));
}
}
Policy { always, https_only }
}
fn push(out: &mut Vec<(HeaderName, HeaderValue)>, name: &'static str, value: &str) {
if value.is_empty() {
return;
}
if let Ok(val) = HeaderValue::try_from(value) {
out.push((HeaderName::from_static(name), val));
}
}
pub(crate) fn apply(resp: &mut Response<ProxyBody>, is_tls: bool) {
let Some(policy) = POLICY.get() else {
return;
};
let headers = resp.headers_mut();
add_if_absent(headers, &policy.always);
if is_tls {
add_if_absent(headers, &policy.https_only);
}
}
fn add_if_absent(map: &mut HeaderMap, entries: &[(HeaderName, HeaderValue)]) {
for (name, value) in entries {
if !map.contains_key(name) {
map.insert(name.clone(), value.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::body::full_body;
fn resp() -> Response<ProxyBody> {
Response::new(full_body(hyper::body::Bytes::new()))
}
fn policy_from(cfg: SecurityHeadersConfig) -> Policy {
build(cfg)
}
fn apply_with(policy: &Policy, resp: &mut Response<ProxyBody>, is_tls: bool) {
let headers = resp.headers_mut();
add_if_absent(headers, &policy.always);
if is_tls {
add_if_absent(headers, &policy.https_only);
}
}
#[test]
fn defaults_add_safe_set_on_https() {
let p = policy_from(SecurityHeadersConfig::default());
let mut r = resp();
apply_with(&p, &mut r, true);
let h = r.headers();
assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
assert_eq!(
h.get("referrer-policy").unwrap(),
"strict-origin-when-cross-origin"
);
assert_eq!(
h.get("strict-transport-security").unwrap(),
"max-age=31536000"
);
assert!(h.get("x-frame-options").is_none());
assert!(h.get("content-security-policy").is_none());
}
#[test]
fn hsts_only_on_tls() {
let p = policy_from(SecurityHeadersConfig::default());
let mut r = resp();
apply_with(&p, &mut r, false);
assert!(r.headers().get("strict-transport-security").is_none());
assert!(r.headers().get("x-content-type-options").is_some());
}
#[test]
fn never_clobbers_backend_header() {
let p = policy_from(SecurityHeadersConfig::default());
let mut r = resp();
r.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
apply_with(&p, &mut r, true);
assert_eq!(r.headers().get("referrer-policy").unwrap(), "no-referrer");
}
#[test]
fn disabled_adds_nothing() {
let p = policy_from(SecurityHeadersConfig {
enabled: false,
..Default::default()
});
let mut r = resp();
apply_with(&p, &mut r, true);
assert!(r.headers().is_empty());
}
#[test]
fn opt_in_frame_options_and_extra() {
let mut extra = std::collections::HashMap::new();
extra.insert(
"permissions-policy".to_string(),
"geolocation=()".to_string(),
);
let p = policy_from(SecurityHeadersConfig {
frame_options: "SAMEORIGIN".to_string(),
extra,
..Default::default()
});
let mut r = resp();
apply_with(&p, &mut r, true);
assert_eq!(r.headers().get("x-frame-options").unwrap(), "SAMEORIGIN");
assert_eq!(
r.headers().get("permissions-policy").unwrap(),
"geolocation=()"
);
}
}