ferro_rs/middleware/
security_headers.rs1use crate::http::{HttpResponse, Request, Response};
10use crate::middleware::{Middleware, Next};
11use async_trait::async_trait;
12
13pub struct SecurityHeaders {
40 x_content_type_options: Option<String>,
41 x_frame_options: Option<String>,
42 content_security_policy: Option<String>,
43 referrer_policy: Option<String>,
44 permissions_policy: Option<String>,
45 cross_origin_opener_policy: Option<String>,
46 x_xss_protection: Option<String>,
47 strict_transport_security: Option<String>,
48}
49
50impl SecurityHeaders {
51 pub fn new() -> Self {
56 Self {
57 x_content_type_options: Some("nosniff".to_string()),
58 x_frame_options: Some("DENY".to_string()),
59 content_security_policy: Some(
60 "default-src 'self'; \
61 script-src 'self' 'unsafe-inline' 'unsafe-eval'; \
62 style-src 'self' 'unsafe-inline'; \
63 img-src 'self' data: blob:; \
64 font-src 'self' data:; \
65 connect-src 'self' ws: wss:; \
66 frame-ancestors 'none'"
67 .to_string(),
68 ),
69 referrer_policy: Some("strict-origin-when-cross-origin".to_string()),
70 permissions_policy: Some("geolocation=(), camera=(), microphone=()".to_string()),
71 cross_origin_opener_policy: Some("same-origin".to_string()),
72 x_xss_protection: Some("0".to_string()),
73 strict_transport_security: None,
74 }
75 }
76
77 pub fn with_hsts(mut self) -> Self {
82 self.strict_transport_security = Some("max-age=31536000; includeSubDomains".to_string());
83 self
84 }
85
86 pub fn with_hsts_preload(mut self) -> Self {
91 self.strict_transport_security =
92 Some("max-age=31536000; includeSubDomains; preload".to_string());
93 self
94 }
95
96 pub fn without_hsts(mut self) -> Self {
98 self.strict_transport_security = None;
99 self
100 }
101
102 pub fn x_frame_options(mut self, value: impl Into<String>) -> Self {
106 self.x_frame_options = Some(value.into());
107 self
108 }
109
110 pub fn content_security_policy(mut self, value: impl Into<String>) -> Self {
112 self.content_security_policy = Some(value.into());
113 self
114 }
115
116 pub fn referrer_policy(mut self, value: impl Into<String>) -> Self {
118 self.referrer_policy = Some(value.into());
119 self
120 }
121
122 pub fn permissions_policy(mut self, value: impl Into<String>) -> Self {
124 self.permissions_policy = Some(value.into());
125 self
126 }
127
128 pub fn cross_origin_opener_policy(mut self, value: impl Into<String>) -> Self {
130 self.cross_origin_opener_policy = Some(value.into());
131 self
132 }
133
134 pub fn without(mut self, header_name: &str) -> Self {
146 match header_name.to_ascii_lowercase().as_str() {
147 "x-content-type-options" => self.x_content_type_options = None,
148 "x-frame-options" => self.x_frame_options = None,
149 "content-security-policy" => self.content_security_policy = None,
150 "referrer-policy" => self.referrer_policy = None,
151 "permissions-policy" => self.permissions_policy = None,
152 "cross-origin-opener-policy" => self.cross_origin_opener_policy = None,
153 "x-xss-protection" => self.x_xss_protection = None,
154 "strict-transport-security" => self.strict_transport_security = None,
155 _ => {}
156 }
157 self
158 }
159
160 pub(crate) fn apply_headers(&self, resp: HttpResponse) -> HttpResponse {
162 let mut resp = resp;
163 if let Some(ref v) = self.x_content_type_options {
164 resp = resp.header("X-Content-Type-Options", v.as_str());
165 }
166 if let Some(ref v) = self.x_frame_options {
167 resp = resp.header("X-Frame-Options", v.as_str());
168 }
169 if let Some(ref v) = self.content_security_policy {
170 resp = resp.header("Content-Security-Policy", v.as_str());
171 }
172 if let Some(ref v) = self.referrer_policy {
173 resp = resp.header("Referrer-Policy", v.as_str());
174 }
175 if let Some(ref v) = self.permissions_policy {
176 resp = resp.header("Permissions-Policy", v.as_str());
177 }
178 if let Some(ref v) = self.cross_origin_opener_policy {
179 resp = resp.header("Cross-Origin-Opener-Policy", v.as_str());
180 }
181 if let Some(ref v) = self.x_xss_protection {
182 resp = resp.header("X-XSS-Protection", v.as_str());
183 }
184 if let Some(ref v) = self.strict_transport_security {
185 resp = resp.header("Strict-Transport-Security", v.as_str());
186 }
187 resp
188 }
189}
190
191impl Default for SecurityHeaders {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197#[async_trait]
198impl Middleware for SecurityHeaders {
199 async fn handle(&self, request: Request, next: Next) -> Response {
200 let response = next(request).await;
201 match response {
202 Ok(resp) => Ok(self.apply_headers(resp)),
203 Err(resp) => Err(self.apply_headers(resp)),
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn test_default_headers() {
214 let sh = SecurityHeaders::new();
215
216 assert_eq!(sh.x_content_type_options.as_deref(), Some("nosniff"));
217 assert_eq!(sh.x_frame_options.as_deref(), Some("DENY"));
218 assert!(sh
219 .content_security_policy
220 .as_ref()
221 .unwrap()
222 .contains("default-src 'self'"));
223 assert!(sh
224 .content_security_policy
225 .as_ref()
226 .unwrap()
227 .contains("frame-ancestors 'none'"));
228 assert_eq!(
229 sh.referrer_policy.as_deref(),
230 Some("strict-origin-when-cross-origin")
231 );
232 assert_eq!(
233 sh.permissions_policy.as_deref(),
234 Some("geolocation=(), camera=(), microphone=()")
235 );
236 assert_eq!(
237 sh.cross_origin_opener_policy.as_deref(),
238 Some("same-origin")
239 );
240 assert_eq!(sh.x_xss_protection.as_deref(), Some("0"));
241 assert!(sh.strict_transport_security.is_none());
242 }
243
244 #[test]
245 fn test_with_hsts() {
246 let sh = SecurityHeaders::new().with_hsts();
247
248 let hsts = sh.strict_transport_security.as_ref().unwrap();
249 assert!(hsts.contains("max-age=31536000"));
250 assert!(hsts.contains("includeSubDomains"));
251 assert!(!hsts.contains("preload"));
252 }
253
254 #[test]
255 fn test_with_hsts_preload() {
256 let sh = SecurityHeaders::new().with_hsts_preload();
257
258 let hsts = sh.strict_transport_security.as_ref().unwrap();
259 assert!(hsts.contains("max-age=31536000"));
260 assert!(hsts.contains("includeSubDomains"));
261 assert!(hsts.contains("preload"));
262 }
263
264 #[test]
265 fn test_builder_overrides() {
266 let sh = SecurityHeaders::new().x_frame_options("SAMEORIGIN");
267 assert_eq!(sh.x_frame_options.as_deref(), Some("SAMEORIGIN"));
268
269 let sh = SecurityHeaders::new().content_security_policy("default-src 'none'");
270 assert_eq!(
271 sh.content_security_policy.as_deref(),
272 Some("default-src 'none'")
273 );
274
275 let sh = SecurityHeaders::new().referrer_policy("no-referrer");
276 assert_eq!(sh.referrer_policy.as_deref(), Some("no-referrer"));
277
278 let sh = SecurityHeaders::new().permissions_policy("camera=(self)");
279 assert_eq!(sh.permissions_policy.as_deref(), Some("camera=(self)"));
280
281 let sh = SecurityHeaders::new().cross_origin_opener_policy("unsafe-none");
282 assert_eq!(
283 sh.cross_origin_opener_policy.as_deref(),
284 Some("unsafe-none")
285 );
286 }
287
288 #[test]
289 fn test_without_disables_header() {
290 let sh = SecurityHeaders::new().without("X-Frame-Options");
291 assert!(sh.x_frame_options.is_none());
292
293 assert!(sh.x_content_type_options.is_some());
295 assert!(sh.content_security_policy.is_some());
296 }
297
298 #[test]
299 fn test_without_case_insensitive() {
300 let sh = SecurityHeaders::new().without("x-frame-options");
301 assert!(sh.x_frame_options.is_none());
302
303 let sh = SecurityHeaders::new().without("PERMISSIONS-POLICY");
304 assert!(sh.permissions_policy.is_none());
305 }
306
307 #[test]
308 fn test_without_unknown_header_is_noop() {
309 let sh = SecurityHeaders::new().without("X-Unknown-Header");
310 assert!(sh.x_content_type_options.is_some());
312 assert!(sh.x_frame_options.is_some());
313 assert!(sh.content_security_policy.is_some());
314 }
315
316 #[test]
317 fn test_apply_headers() {
318 let sh = SecurityHeaders::new();
319 let resp = HttpResponse::text("ok");
320 let resp = sh.apply_headers(resp);
321 let hyper_resp = resp.into_hyper();
322
323 assert_eq!(
324 hyper_resp.headers().get("X-Content-Type-Options").unwrap(),
325 "nosniff"
326 );
327 assert_eq!(hyper_resp.headers().get("X-Frame-Options").unwrap(), "DENY");
328 assert!(hyper_resp
329 .headers()
330 .get("Content-Security-Policy")
331 .unwrap()
332 .to_str()
333 .unwrap()
334 .contains("default-src 'self'"));
335 assert_eq!(
336 hyper_resp.headers().get("Referrer-Policy").unwrap(),
337 "strict-origin-when-cross-origin"
338 );
339 assert_eq!(
340 hyper_resp.headers().get("Permissions-Policy").unwrap(),
341 "geolocation=(), camera=(), microphone=()"
342 );
343 assert_eq!(
344 hyper_resp
345 .headers()
346 .get("Cross-Origin-Opener-Policy")
347 .unwrap(),
348 "same-origin"
349 );
350 assert_eq!(hyper_resp.headers().get("X-XSS-Protection").unwrap(), "0");
351 assert!(hyper_resp
353 .headers()
354 .get("Strict-Transport-Security")
355 .is_none());
356 }
357
358 #[test]
359 fn test_apply_headers_with_hsts() {
360 let sh = SecurityHeaders::new().with_hsts();
361 let resp = HttpResponse::text("ok");
362 let resp = sh.apply_headers(resp);
363 let hyper_resp = resp.into_hyper();
364
365 assert!(hyper_resp
366 .headers()
367 .get("Strict-Transport-Security")
368 .is_some());
369 }
370
371 #[test]
372 fn test_apply_headers_without_disabled() {
373 let sh = SecurityHeaders::new()
374 .without("X-Frame-Options")
375 .without("Permissions-Policy");
376 let resp = HttpResponse::text("ok");
377 let resp = sh.apply_headers(resp);
378 let hyper_resp = resp.into_hyper();
379
380 assert!(hyper_resp.headers().get("X-Frame-Options").is_none());
381 assert!(hyper_resp.headers().get("Permissions-Policy").is_none());
382 assert!(hyper_resp.headers().get("X-Content-Type-Options").is_some());
384 }
385
386 #[test]
387 fn test_default_impl() {
388 let from_new = SecurityHeaders::new();
389 let from_default = SecurityHeaders::default();
390
391 assert_eq!(
392 from_new.x_content_type_options,
393 from_default.x_content_type_options
394 );
395 assert_eq!(from_new.x_frame_options, from_default.x_frame_options);
396 assert_eq!(
397 from_new.content_security_policy,
398 from_default.content_security_policy
399 );
400 assert_eq!(from_new.referrer_policy, from_default.referrer_policy);
401 assert_eq!(from_new.permissions_policy, from_default.permissions_policy);
402 assert_eq!(
403 from_new.cross_origin_opener_policy,
404 from_default.cross_origin_opener_policy
405 );
406 assert_eq!(from_new.x_xss_protection, from_default.x_xss_protection);
407 assert_eq!(
408 from_new.strict_transport_security,
409 from_default.strict_transport_security
410 );
411 }
412}