1use std::future::{ready, Future, Ready};
30use std::pin::Pin;
31use std::rc::Rc;
32use std::task::{Context, Poll};
33
34use actix_service::{Service, Transform};
35use actix_web::dev::{ServiceRequest, ServiceResponse};
36use actix_web::http::header::{HeaderName, HeaderValue};
37use actix_web::Error;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum FrameOptions {
42 Deny,
44 SameOrigin,
46 Disabled,
48}
49
50impl FrameOptions {
51 fn to_header_value(&self) -> Option<&'static str> {
52 match self {
53 FrameOptions::Deny => Some("DENY"),
54 FrameOptions::SameOrigin => Some("SAMEORIGIN"),
55 FrameOptions::Disabled => None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum ReferrerPolicy {
63 NoReferrer,
64 NoReferrerWhenDowngrade,
65 Origin,
66 OriginWhenCrossOrigin,
67 SameOrigin,
68 StrictOrigin,
69 StrictOriginWhenCrossOrigin,
70 UnsafeUrl,
71 Disabled,
72}
73
74impl ReferrerPolicy {
75 fn to_header_value(&self) -> Option<&'static str> {
76 match self {
77 ReferrerPolicy::NoReferrer => Some("no-referrer"),
78 ReferrerPolicy::NoReferrerWhenDowngrade => Some("no-referrer-when-downgrade"),
79 ReferrerPolicy::Origin => Some("origin"),
80 ReferrerPolicy::OriginWhenCrossOrigin => Some("origin-when-cross-origin"),
81 ReferrerPolicy::SameOrigin => Some("same-origin"),
82 ReferrerPolicy::StrictOrigin => Some("strict-origin"),
83 ReferrerPolicy::StrictOriginWhenCrossOrigin => Some("strict-origin-when-cross-origin"),
84 ReferrerPolicy::UnsafeUrl => Some("unsafe-url"),
85 ReferrerPolicy::Disabled => None,
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
105pub struct SecurityHeaders {
106 pub content_type_options: bool,
108 pub frame_options: FrameOptions,
110 pub xss_protection: bool,
112 pub content_security_policy: Option<String>,
114 pub hsts_enabled: bool,
116 pub hsts_max_age: u64,
118 pub hsts_include_subdomains: bool,
120 pub hsts_preload: bool,
122 pub referrer_policy: ReferrerPolicy,
124 pub permissions_policy: Option<String>,
126 pub cache_control: Option<String>,
128}
129
130impl Default for SecurityHeaders {
131 fn default() -> Self {
139 SecurityHeaders {
140 content_type_options: true,
141 frame_options: FrameOptions::Deny,
142 xss_protection: false, content_security_policy: None,
144 hsts_enabled: false,
145 hsts_max_age: 31536000, hsts_include_subdomains: false,
147 hsts_preload: false,
148 referrer_policy: ReferrerPolicy::StrictOriginWhenCrossOrigin,
149 permissions_policy: None,
150 cache_control: None,
151 }
152 }
153}
154
155impl SecurityHeaders {
156 pub fn new() -> Self {
158 Self::default()
159 }
160
161 pub fn strict() -> Self {
165 SecurityHeaders {
166 content_type_options: true,
167 frame_options: FrameOptions::Deny,
168 xss_protection: false,
169 content_security_policy: Some("default-src 'self'".to_string()),
170 hsts_enabled: true,
171 hsts_max_age: 31536000,
172 hsts_include_subdomains: true,
173 hsts_preload: false,
174 referrer_policy: ReferrerPolicy::NoReferrer,
175 permissions_policy: Some("geolocation=(), microphone=(), camera=()".to_string()),
176 cache_control: Some("no-cache, no-store, must-revalidate".to_string()),
177 }
178 }
179
180 pub fn frame_options(mut self, options: FrameOptions) -> Self {
185 self.frame_options = options;
186 self
187 }
188
189 pub fn content_security_policy(mut self, policy: impl Into<String>) -> Self {
200 self.content_security_policy = Some(policy.into());
201 self
202 }
203
204 pub fn hsts(mut self, enabled: bool, max_age: u64) -> Self {
213 self.hsts_enabled = enabled;
214 self.hsts_max_age = max_age;
215 self
216 }
217
218 pub fn hsts_include_subdomains(mut self, include: bool) -> Self {
220 self.hsts_include_subdomains = include;
221 self
222 }
223
224 pub fn hsts_preload(mut self, preload: bool) -> Self {
229 self.hsts_preload = preload;
230 self
231 }
232
233 pub fn referrer_policy(mut self, policy: ReferrerPolicy) -> Self {
238 self.referrer_policy = policy;
239 self
240 }
241
242 pub fn permissions_policy(mut self, policy: impl Into<String>) -> Self {
250 self.permissions_policy = Some(policy.into());
251 self
252 }
253
254 pub fn cache_control(mut self, value: impl Into<String>) -> Self {
256 self.cache_control = Some(value.into());
257 self
258 }
259
260 pub fn disable_content_type_options(mut self) -> Self {
262 self.content_type_options = false;
263 self
264 }
265
266 fn build_hsts_value(&self) -> String {
267 let mut value = format!("max-age={}", self.hsts_max_age);
268 if self.hsts_include_subdomains {
269 value.push_str("; includeSubDomains");
270 }
271 if self.hsts_preload {
272 value.push_str("; preload");
273 }
274 value
275 }
276}
277
278impl<S, B> Transform<S, ServiceRequest> for SecurityHeaders
279where
280 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
281 B: 'static,
282{
283 type Response = ServiceResponse<B>;
284 type Error = Error;
285 type Transform = SecurityHeadersMiddleware<S>;
286 type InitError = ();
287 type Future = Ready<Result<Self::Transform, Self::InitError>>;
288
289 fn new_transform(&self, service: S) -> Self::Future {
290 ready(Ok(SecurityHeadersMiddleware {
291 service: Rc::new(service),
292 config: self.clone(),
293 }))
294 }
295}
296
297pub struct SecurityHeadersMiddleware<S> {
299 service: Rc<S>,
300 config: SecurityHeaders,
301}
302
303impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddleware<S>
304where
305 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
306 B: 'static,
307{
308 type Response = ServiceResponse<B>;
309 type Error = Error;
310 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
311
312 fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
313 self.service.poll_ready(ctx)
314 }
315
316 fn call(&self, req: ServiceRequest) -> Self::Future {
317 let service = Rc::clone(&self.service);
318 let config = self.config.clone();
319
320 Box::pin(async move {
321 let mut response = service.call(req).await?;
322
323 let headers = response.headers_mut();
324
325 if config.content_type_options {
327 headers.insert(
328 HeaderName::from_static("x-content-type-options"),
329 HeaderValue::from_static("nosniff"),
330 );
331 }
332
333 if let Some(value) = config.frame_options.to_header_value() {
335 headers.insert(
336 HeaderName::from_static("x-frame-options"),
337 HeaderValue::from_static(value),
338 );
339 }
340
341 headers.insert(
343 HeaderName::from_static("x-xss-protection"),
344 HeaderValue::from_static(if config.xss_protection {
345 "1; mode=block"
346 } else {
347 "0"
348 }),
349 );
350
351 if let Some(ref csp) = config.content_security_policy {
353 if let Ok(value) = HeaderValue::from_str(csp) {
354 headers.insert(HeaderName::from_static("content-security-policy"), value);
355 }
356 }
357
358 if config.hsts_enabled {
360 let hsts_value = config.build_hsts_value();
361 if let Ok(value) = HeaderValue::from_str(&hsts_value) {
362 headers.insert(HeaderName::from_static("strict-transport-security"), value);
363 }
364 }
365
366 if let Some(value) = config.referrer_policy.to_header_value() {
368 headers.insert(
369 HeaderName::from_static("referrer-policy"),
370 HeaderValue::from_static(value),
371 );
372 }
373
374 if let Some(ref policy) = config.permissions_policy {
376 if let Ok(value) = HeaderValue::from_str(policy) {
377 headers.insert(HeaderName::from_static("permissions-policy"), value);
378 }
379 }
380
381 if let Some(ref cache) = config.cache_control {
383 if let Ok(value) = HeaderValue::from_str(cache) {
384 headers.insert(HeaderName::from_static("cache-control"), value);
385 }
386 }
387
388 Ok(response)
389 })
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
402 fn test_frame_options_deny() {
403 assert_eq!(FrameOptions::Deny.to_header_value(), Some("DENY"));
404 }
405
406 #[test]
407 fn test_frame_options_same_origin() {
408 assert_eq!(
409 FrameOptions::SameOrigin.to_header_value(),
410 Some("SAMEORIGIN")
411 );
412 }
413
414 #[test]
415 fn test_frame_options_disabled() {
416 assert_eq!(FrameOptions::Disabled.to_header_value(), None);
417 }
418
419 #[test]
420 fn test_frame_options_equality() {
421 assert_eq!(FrameOptions::Deny, FrameOptions::Deny);
422 assert_ne!(FrameOptions::Deny, FrameOptions::SameOrigin);
423 }
424
425 #[test]
430 fn test_referrer_policy_values() {
431 assert_eq!(
432 ReferrerPolicy::NoReferrer.to_header_value(),
433 Some("no-referrer")
434 );
435 assert_eq!(
436 ReferrerPolicy::NoReferrerWhenDowngrade.to_header_value(),
437 Some("no-referrer-when-downgrade")
438 );
439 assert_eq!(ReferrerPolicy::Origin.to_header_value(), Some("origin"));
440 assert_eq!(
441 ReferrerPolicy::OriginWhenCrossOrigin.to_header_value(),
442 Some("origin-when-cross-origin")
443 );
444 assert_eq!(
445 ReferrerPolicy::SameOrigin.to_header_value(),
446 Some("same-origin")
447 );
448 assert_eq!(
449 ReferrerPolicy::StrictOrigin.to_header_value(),
450 Some("strict-origin")
451 );
452 assert_eq!(
453 ReferrerPolicy::StrictOriginWhenCrossOrigin.to_header_value(),
454 Some("strict-origin-when-cross-origin")
455 );
456 assert_eq!(
457 ReferrerPolicy::UnsafeUrl.to_header_value(),
458 Some("unsafe-url")
459 );
460 assert_eq!(ReferrerPolicy::Disabled.to_header_value(), None);
461 }
462
463 #[test]
468 fn test_default_security_headers() {
469 let headers = SecurityHeaders::default();
470
471 assert!(headers.content_type_options);
472 assert_eq!(headers.frame_options, FrameOptions::Deny);
473 assert!(!headers.xss_protection);
474 assert!(headers.content_security_policy.is_none());
475 assert!(!headers.hsts_enabled);
476 assert_eq!(headers.hsts_max_age, 31536000);
477 assert!(!headers.hsts_include_subdomains);
478 assert!(!headers.hsts_preload);
479 assert_eq!(
480 headers.referrer_policy,
481 ReferrerPolicy::StrictOriginWhenCrossOrigin
482 );
483 assert!(headers.permissions_policy.is_none());
484 assert!(headers.cache_control.is_none());
485 }
486
487 #[test]
488 fn test_new_equals_default() {
489 let new = SecurityHeaders::new();
490 let default = SecurityHeaders::default();
491
492 assert_eq!(new.content_type_options, default.content_type_options);
493 assert_eq!(new.frame_options, default.frame_options);
494 assert_eq!(new.hsts_enabled, default.hsts_enabled);
495 }
496
497 #[test]
502 fn test_strict_security_headers() {
503 let headers = SecurityHeaders::strict();
504
505 assert!(headers.content_type_options);
506 assert_eq!(headers.frame_options, FrameOptions::Deny);
507 assert!(headers.content_security_policy.is_some());
508 assert_eq!(
509 headers.content_security_policy.as_deref(),
510 Some("default-src 'self'")
511 );
512 assert!(headers.hsts_enabled);
513 assert!(headers.hsts_include_subdomains);
514 assert!(!headers.hsts_preload);
515 assert_eq!(headers.referrer_policy, ReferrerPolicy::NoReferrer);
516 assert!(headers.permissions_policy.is_some());
517 assert!(headers.cache_control.is_some());
518 }
519
520 #[test]
525 fn test_frame_options_builder() {
526 let headers = SecurityHeaders::new().frame_options(FrameOptions::SameOrigin);
527
528 assert_eq!(headers.frame_options, FrameOptions::SameOrigin);
529 }
530
531 #[test]
532 fn test_content_security_policy_builder() {
533 let headers =
534 SecurityHeaders::new().content_security_policy("default-src 'self'; script-src 'self'");
535
536 assert_eq!(
537 headers.content_security_policy.as_deref(),
538 Some("default-src 'self'; script-src 'self'")
539 );
540 }
541
542 #[test]
543 fn test_hsts_builder() {
544 let headers = SecurityHeaders::new().hsts(true, 86400);
545
546 assert!(headers.hsts_enabled);
547 assert_eq!(headers.hsts_max_age, 86400);
548 }
549
550 #[test]
551 fn test_hsts_include_subdomains_builder() {
552 let headers = SecurityHeaders::new().hsts_include_subdomains(true);
553
554 assert!(headers.hsts_include_subdomains);
555 }
556
557 #[test]
558 fn test_hsts_preload_builder() {
559 let headers = SecurityHeaders::new().hsts_preload(true);
560
561 assert!(headers.hsts_preload);
562 }
563
564 #[test]
565 fn test_referrer_policy_builder() {
566 let headers = SecurityHeaders::new().referrer_policy(ReferrerPolicy::NoReferrer);
567
568 assert_eq!(headers.referrer_policy, ReferrerPolicy::NoReferrer);
569 }
570
571 #[test]
572 fn test_permissions_policy_builder() {
573 let headers = SecurityHeaders::new().permissions_policy("geolocation=(), camera=()");
574
575 assert_eq!(
576 headers.permissions_policy.as_deref(),
577 Some("geolocation=(), camera=()")
578 );
579 }
580
581 #[test]
582 fn test_cache_control_builder() {
583 let headers = SecurityHeaders::new().cache_control("no-cache, no-store");
584
585 assert_eq!(headers.cache_control.as_deref(), Some("no-cache, no-store"));
586 }
587
588 #[test]
589 fn test_disable_content_type_options() {
590 let headers = SecurityHeaders::new().disable_content_type_options();
591
592 assert!(!headers.content_type_options);
593 }
594
595 #[test]
596 fn test_chained_builders() {
597 let headers = SecurityHeaders::new()
598 .frame_options(FrameOptions::SameOrigin)
599 .content_security_policy("default-src 'self'")
600 .hsts(true, 86400)
601 .hsts_include_subdomains(true)
602 .referrer_policy(ReferrerPolicy::StrictOrigin)
603 .permissions_policy("geolocation=()")
604 .cache_control("private");
605
606 assert_eq!(headers.frame_options, FrameOptions::SameOrigin);
607 assert!(headers.content_security_policy.is_some());
608 assert!(headers.hsts_enabled);
609 assert!(headers.hsts_include_subdomains);
610 assert_eq!(headers.referrer_policy, ReferrerPolicy::StrictOrigin);
611 assert!(headers.permissions_policy.is_some());
612 assert!(headers.cache_control.is_some());
613 }
614
615 #[test]
620 fn test_build_hsts_value_basic() {
621 let headers = SecurityHeaders::new().hsts(true, 31536000);
622
623 assert_eq!(headers.build_hsts_value(), "max-age=31536000");
624 }
625
626 #[test]
627 fn test_build_hsts_value_with_subdomains() {
628 let headers = SecurityHeaders::new()
629 .hsts(true, 31536000)
630 .hsts_include_subdomains(true);
631
632 assert_eq!(
633 headers.build_hsts_value(),
634 "max-age=31536000; includeSubDomains"
635 );
636 }
637
638 #[test]
639 fn test_build_hsts_value_with_preload() {
640 let headers = SecurityHeaders::new()
641 .hsts(true, 31536000)
642 .hsts_preload(true);
643
644 assert_eq!(headers.build_hsts_value(), "max-age=31536000; preload");
645 }
646
647 #[test]
648 fn test_build_hsts_value_full() {
649 let headers = SecurityHeaders::new()
650 .hsts(true, 31536000)
651 .hsts_include_subdomains(true)
652 .hsts_preload(true);
653
654 assert_eq!(
655 headers.build_hsts_value(),
656 "max-age=31536000; includeSubDomains; preload"
657 );
658 }
659
660 #[test]
665 fn test_security_headers_clone() {
666 let original = SecurityHeaders::new()
667 .frame_options(FrameOptions::SameOrigin)
668 .content_security_policy("default-src 'self'");
669
670 let cloned = original.clone();
671
672 assert_eq!(cloned.frame_options, original.frame_options);
673 assert_eq!(
674 cloned.content_security_policy,
675 original.content_security_policy
676 );
677 }
678
679 #[test]
684 fn test_security_headers_debug() {
685 let headers = SecurityHeaders::new();
686 let debug_str = format!("{:?}", headers);
687
688 assert!(debug_str.contains("SecurityHeaders"));
689 }
690
691 #[test]
692 fn test_frame_options_debug() {
693 let deny = FrameOptions::Deny;
694 let debug_str = format!("{:?}", deny);
695
696 assert!(debug_str.contains("Deny"));
697 }
698
699 #[test]
700 fn test_referrer_policy_debug() {
701 let policy = ReferrerPolicy::StrictOriginWhenCrossOrigin;
702 let debug_str = format!("{:?}", policy);
703
704 assert!(debug_str.contains("StrictOriginWhenCrossOrigin"));
705 }
706}