orca_proxy/
security_headers.rs1use 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
24struct Policy {
26 always: Vec<(HeaderName, HeaderValue)>,
28 https_only: Vec<(HeaderName, HeaderValue)>,
30}
31
32pub 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 push(&mut always, "x-frame-options", &cfg.frame_options);
57 push(&mut always, "content-security-policy", &cfg.csp);
58 push(&mut https_only, "strict-transport-security", &cfg.hsts);
60
61 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
74fn 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
85pub(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 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 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}