Skip to main content

orca_proxy/
security_headers.rs

1//! Baseline security response headers injected by the proxy.
2//!
3//! The proxy is the TLS terminator and the single ingress for every service, so
4//! it's the right place to add safe defaults (HSTS, `nosniff`, `Referrer-Policy`)
5//! once rather than per app. Headers are added **only if the backend didn't set
6//! them**, so an app's own `Content-Security-Policy` / `X-Frame-Options` /
7//! `Strict-Transport-Security` always wins — this is the pass-through-plus-
8//! defaults model used by Traefik/Caddy.
9//!
10//! The policy is process-global, installed once at startup from `cluster.toml`
11//! via [`init`]. If `init` is never called (e.g. library/test callers), [`apply`]
12//! is a no-op, so it never changes behavior for those paths.
13
14use std::sync::OnceLock;
15
16use hyper::Response;
17use hyper::header::{HeaderMap, HeaderName, HeaderValue};
18
19use crate::body::ProxyBody;
20use orca_core::config::SecurityHeadersConfig;
21
22static POLICY: OnceLock<Policy> = OnceLock::new();
23
24/// Resolved header set, precomputed once from config.
25struct Policy {
26    /// Added to every response (e.g. `nosniff`, `Referrer-Policy`).
27    always: Vec<(HeaderName, HeaderValue)>,
28    /// Added only to HTTPS responses — HSTS is invalid/ignored over plain HTTP.
29    https_only: Vec<(HeaderName, HeaderValue)>,
30}
31
32/// Install the proxy's security-header policy from config. Call once at startup
33/// before serving. `None` installs the safe default set (on); a disabled config
34/// installs an empty (no-op) policy.
35pub fn init(config: Option<SecurityHeadersConfig>) {
36    let _ = POLICY.set(build(config.unwrap_or_default()));
37}
38
39fn build(cfg: SecurityHeadersConfig) -> Policy {
40    if !cfg.enabled {
41        return Policy {
42            always: Vec::new(),
43            https_only: Vec::new(),
44        };
45    }
46    let mut always = Vec::new();
47    let mut https_only = Vec::new();
48
49    push(
50        &mut always,
51        "x-content-type-options",
52        &cfg.content_type_options,
53    );
54    push(&mut always, "referrer-policy", &cfg.referrer_policy);
55    // Off by default (empty) — opt-in, since these break embedded / most apps.
56    push(&mut always, "x-frame-options", &cfg.frame_options);
57    push(&mut always, "content-security-policy", &cfg.csp);
58    // HSTS only makes sense over TLS.
59    push(&mut https_only, "strict-transport-security", &cfg.hsts);
60
61    // Arbitrary operator-supplied extras (add-if-absent like the rest).
62    for (k, v) in &cfg.extra {
63        if let (Ok(name), Ok(val)) = (
64            HeaderName::try_from(k.as_str()),
65            HeaderValue::try_from(v.as_str()),
66        ) {
67            always.push((name, val));
68        }
69    }
70
71    Policy { always, https_only }
72}
73
74/// Push a `(name, value)` pair, skipping empty values (= "disable this header")
75/// and any value that isn't a valid header value.
76fn push(out: &mut Vec<(HeaderName, HeaderValue)>, name: &'static str, value: &str) {
77    if value.is_empty() {
78        return;
79    }
80    if let Ok(val) = HeaderValue::try_from(value) {
81        out.push((HeaderName::from_static(name), val));
82    }
83}
84
85/// Apply the configured security headers to a response — **add-if-absent**, so a
86/// header the backend already set is never overwritten. HSTS is added only when
87/// `is_tls`. No-op if [`init`] was never called.
88pub(crate) fn apply(resp: &mut Response<ProxyBody>, is_tls: bool) {
89    let Some(policy) = POLICY.get() else {
90        return;
91    };
92    let headers = resp.headers_mut();
93    add_if_absent(headers, &policy.always);
94    if is_tls {
95        add_if_absent(headers, &policy.https_only);
96    }
97}
98
99fn add_if_absent(map: &mut HeaderMap, entries: &[(HeaderName, HeaderValue)]) {
100    for (name, value) in entries {
101        if !map.contains_key(name) {
102            map.insert(name.clone(), value.clone());
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::body::full_body;
111
112    fn resp() -> Response<ProxyBody> {
113        Response::new(full_body(hyper::body::Bytes::new()))
114    }
115
116    fn policy_from(cfg: SecurityHeadersConfig) -> Policy {
117        build(cfg)
118    }
119
120    fn apply_with(policy: &Policy, resp: &mut Response<ProxyBody>, is_tls: bool) {
121        let headers = resp.headers_mut();
122        add_if_absent(headers, &policy.always);
123        if is_tls {
124            add_if_absent(headers, &policy.https_only);
125        }
126    }
127
128    #[test]
129    fn defaults_add_safe_set_on_https() {
130        let p = policy_from(SecurityHeadersConfig::default());
131        let mut r = resp();
132        apply_with(&p, &mut r, true);
133        let h = r.headers();
134        assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
135        assert_eq!(
136            h.get("referrer-policy").unwrap(),
137            "strict-origin-when-cross-origin"
138        );
139        assert_eq!(
140            h.get("strict-transport-security").unwrap(),
141            "max-age=31536000"
142        );
143        // Off by default.
144        assert!(h.get("x-frame-options").is_none());
145        assert!(h.get("content-security-policy").is_none());
146    }
147
148    #[test]
149    fn hsts_only_on_tls() {
150        let p = policy_from(SecurityHeadersConfig::default());
151        let mut r = resp();
152        apply_with(&p, &mut r, false);
153        assert!(r.headers().get("strict-transport-security").is_none());
154        assert!(r.headers().get("x-content-type-options").is_some());
155    }
156
157    #[test]
158    fn never_clobbers_backend_header() {
159        let p = policy_from(SecurityHeadersConfig::default());
160        let mut r = resp();
161        r.headers_mut()
162            .insert("referrer-policy", HeaderValue::from_static("no-referrer"));
163        apply_with(&p, &mut r, true);
164        // Backend's value is preserved (pass-through), not overwritten.
165        assert_eq!(r.headers().get("referrer-policy").unwrap(), "no-referrer");
166    }
167
168    #[test]
169    fn disabled_adds_nothing() {
170        let p = policy_from(SecurityHeadersConfig {
171            enabled: false,
172            ..Default::default()
173        });
174        let mut r = resp();
175        apply_with(&p, &mut r, true);
176        assert!(r.headers().is_empty());
177    }
178
179    #[test]
180    fn opt_in_frame_options_and_extra() {
181        let mut extra = std::collections::HashMap::new();
182        extra.insert(
183            "permissions-policy".to_string(),
184            "geolocation=()".to_string(),
185        );
186        let p = policy_from(SecurityHeadersConfig {
187            frame_options: "SAMEORIGIN".to_string(),
188            extra,
189            ..Default::default()
190        });
191        let mut r = resp();
192        apply_with(&p, &mut r, true);
193        assert_eq!(r.headers().get("x-frame-options").unwrap(), "SAMEORIGIN");
194        assert_eq!(
195            r.headers().get("permissions-policy").unwrap(),
196            "geolocation=()"
197        );
198    }
199}