impulse_server_kit/
security_headers.rs1use std::sync::Arc;
35
36use salvo::http::HeaderValue;
37use salvo::http::header::{
38 CONTENT_SECURITY_POLICY, HeaderName, REFERRER_POLICY, STRICT_TRANSPORT_SECURITY, X_CONTENT_TYPE_OPTIONS,
39 X_FRAME_OPTIONS,
40};
41use salvo::prelude::*;
42use serde::Deserialize;
43
44use crate::setup::{GenericServerState, StartupVariant};
45
46#[derive(Clone, Debug, Deserialize)]
53#[serde(default)]
54pub struct SecurityHeadersOptions {
55 pub enabled: bool,
57 pub hsts: Option<String>,
60 pub x_content_type_options: Option<String>,
62 pub x_frame_options: Option<String>,
64 pub referrer_policy: Option<String>,
66 pub content_security_policy: Option<String>,
68 pub permissions_policy: Option<String>,
70 pub cross_origin_opener_policy: Option<String>,
72}
73
74impl Default for SecurityHeadersOptions {
75 fn default() -> Self {
76 Self {
77 enabled: true,
78 hsts: Some("max-age=31536000; includeSubDomains".to_string()),
79 x_content_type_options: Some("nosniff".to_string()),
80 x_frame_options: Some("SAMEORIGIN".to_string()),
81 referrer_policy: Some("strict-origin-when-cross-origin".to_string()),
82 content_security_policy: None,
83 permissions_policy: None,
84 cross_origin_opener_policy: None,
85 }
86 }
87}
88
89pub struct SecurityHeaders {
93 always: Arc<Vec<(HeaderName, HeaderValue)>>,
94 hsts: Option<(HeaderName, HeaderValue)>,
95}
96
97fn parse(name: HeaderName, value: &Option<String>) -> Option<(HeaderName, HeaderValue)> {
98 let raw = value.as_deref()?;
99 match HeaderValue::from_str(raw) {
100 Ok(v) => Some((name, v)),
101 Err(e) => {
102 tracing::warn!(error = %e, header = %name, raw = %raw, "ignoring invalid security header value");
103 None
104 }
105 }
106}
107
108impl SecurityHeaders {
109 pub fn new(options: &SecurityHeadersOptions) -> Self {
112 let mut always = Vec::new();
113 always.extend(parse(X_CONTENT_TYPE_OPTIONS, &options.x_content_type_options));
114 always.extend(parse(X_FRAME_OPTIONS, &options.x_frame_options));
115 always.extend(parse(REFERRER_POLICY, &options.referrer_policy));
116 always.extend(parse(CONTENT_SECURITY_POLICY, &options.content_security_policy));
117 always.extend(parse(
118 HeaderName::from_static("permissions-policy"),
119 &options.permissions_policy,
120 ));
121 always.extend(parse(
122 HeaderName::from_static("cross-origin-opener-policy"),
123 &options.cross_origin_opener_policy,
124 ));
125 let hsts = parse(STRICT_TRANSPORT_SECURITY, &options.hsts);
126 Self {
127 always: Arc::new(always),
128 hsts,
129 }
130 }
131
132 fn apply(&self, hsts_allowed: bool, headers: &mut salvo::http::HeaderMap) {
133 for (name, value) in self.always.iter() {
134 if !headers.contains_key(name) {
135 headers.insert(name.clone(), value.clone());
136 }
137 }
138 if hsts_allowed
139 && let Some((name, value)) = &self.hsts
140 && !headers.contains_key(name)
141 {
142 headers.insert(name.clone(), value.clone());
143 }
144 }
145}
146
147#[salvo::async_trait]
148impl Handler for SecurityHeaders {
149 async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) {
150 ctrl.call_next(req, depot, res).await;
151
152 let hsts_allowed = depot
156 .obtain::<GenericServerState>()
157 .map(|s| {
158 !matches!(
159 s.startup_variant,
160 StartupVariant::HttpLocalhost | StartupVariant::UnsafeHttp
161 )
162 })
163 .unwrap_or(true);
164
165 self.apply(hsts_allowed, res.headers_mut());
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use salvo::http::HeaderMap;
173
174 fn build(opts: SecurityHeadersOptions) -> SecurityHeaders {
175 SecurityHeaders::new(&opts)
176 }
177
178 #[test]
179 fn defaults_emit_the_four_baseline_headers() {
180 let mut headers = HeaderMap::new();
181 build(SecurityHeadersOptions::default()).apply(true, &mut headers);
182
183 assert_eq!(headers["x-content-type-options"], "nosniff");
184 assert_eq!(headers["x-frame-options"], "SAMEORIGIN");
185 assert_eq!(headers["referrer-policy"], "strict-origin-when-cross-origin");
186 assert_eq!(
187 headers["strict-transport-security"],
188 "max-age=31536000; includeSubDomains"
189 );
190 assert!(!headers.contains_key("content-security-policy"));
191 assert!(!headers.contains_key("permissions-policy"));
192 }
193
194 #[test]
195 fn skips_hsts_over_plain_http() {
196 let mut headers = HeaderMap::new();
197 build(SecurityHeadersOptions::default()).apply(false, &mut headers);
198 assert!(!headers.contains_key("strict-transport-security"));
199 assert_eq!(headers["x-content-type-options"], "nosniff");
201 }
202
203 #[test]
204 fn null_disables_specific_header() {
205 let opts = SecurityHeadersOptions {
206 x_frame_options: None,
207 ..Default::default()
208 };
209 let mut headers = HeaderMap::new();
210 build(opts).apply(true, &mut headers);
211 assert!(!headers.contains_key("x-frame-options"));
212 assert_eq!(headers["x-content-type-options"], "nosniff");
213 }
214
215 #[test]
216 fn does_not_overwrite_existing_headers() {
217 let mut headers = HeaderMap::new();
218 headers.insert("x-frame-options", "DENY".parse().unwrap());
219 build(SecurityHeadersOptions::default()).apply(true, &mut headers);
220 assert_eq!(headers["x-frame-options"], "DENY");
222 }
223
224 #[test]
225 fn optional_headers_appear_when_set() {
226 let opts = SecurityHeadersOptions {
227 content_security_policy: Some("default-src 'self'".to_string()),
228 permissions_policy: Some("camera=()".to_string()),
229 cross_origin_opener_policy: Some("same-origin".to_string()),
230 ..Default::default()
231 };
232 let mut headers = HeaderMap::new();
233 build(opts).apply(true, &mut headers);
234 assert_eq!(headers["content-security-policy"], "default-src 'self'");
235 assert_eq!(headers["permissions-policy"], "camera=()");
236 assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
237 }
238
239 #[test]
240 fn invalid_value_is_dropped_not_panicked() {
241 let opts = SecurityHeadersOptions {
242 x_frame_options: Some("bad\nvalue".to_string()),
243 ..Default::default()
244 };
245 let mut headers = HeaderMap::new();
246 build(opts).apply(true, &mut headers);
247 assert!(!headers.contains_key("x-frame-options"));
249 assert_eq!(headers["x-content-type-options"], "nosniff");
250 }
251}