Skip to main content

actix_security_core/http/security/
headers.rs

1//! Security headers middleware for HTTP security.
2//!
3//! # Spring Security Equivalent
4//! `HttpSecurity.headers()` configuration
5//!
6//! # Overview
7//! Adds security-related HTTP headers to responses:
8//!
9//! - `X-Content-Type-Options: nosniff` - Prevents MIME-sniffing
10//! - `X-Frame-Options: DENY` - Prevents clickjacking
11//! - `X-XSS-Protection: 0` - Disables XSS Auditor (deprecated but safe)
12//! - `Strict-Transport-Security` - Forces HTTPS (HSTS)
13//! - `Content-Security-Policy` - Controls resource loading
14//! - `Referrer-Policy` - Controls referrer information
15//! - `Permissions-Policy` - Controls browser features
16//!
17//! # Usage
18//! ```ignore
19//! use actix_web::{App, HttpServer};
20//! use actix_security_core::http::security::headers::SecurityHeaders;
21//!
22//! HttpServer::new(|| {
23//!     App::new()
24//!         .wrap(SecurityHeaders::default())
25//!         // ... routes
26//! })
27//! ```
28
29use 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/// Frame options for X-Frame-Options header.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum FrameOptions {
42    /// Prevents the page from being framed entirely.
43    Deny,
44    /// Allows framing by the same origin only.
45    SameOrigin,
46    /// Disables X-Frame-Options header.
47    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/// Referrer policy options.
61#[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/// Security headers configuration.
91///
92/// # Spring Security Equivalent
93/// `HttpSecurity.headers()`
94///
95/// # Example
96/// ```ignore
97/// use actix_security_core::http::security::headers::{SecurityHeaders, FrameOptions};
98///
99/// let headers = SecurityHeaders::new()
100///     .frame_options(FrameOptions::SameOrigin)
101///     .content_security_policy("default-src 'self'")
102///     .hsts(true, 31536000); // 1 year
103/// ```
104#[derive(Debug, Clone)]
105pub struct SecurityHeaders {
106    /// X-Content-Type-Options header (default: nosniff)
107    pub content_type_options: bool,
108    /// X-Frame-Options header (default: DENY)
109    pub frame_options: FrameOptions,
110    /// X-XSS-Protection header (default: 0)
111    pub xss_protection: bool,
112    /// Content-Security-Policy header (default: None)
113    pub content_security_policy: Option<String>,
114    /// Strict-Transport-Security header (default: disabled)
115    pub hsts_enabled: bool,
116    /// HSTS max-age in seconds (default: 31536000 = 1 year)
117    pub hsts_max_age: u64,
118    /// HSTS include subdomains (default: false)
119    pub hsts_include_subdomains: bool,
120    /// HSTS preload (default: false)
121    pub hsts_preload: bool,
122    /// Referrer-Policy header (default: strict-origin-when-cross-origin)
123    pub referrer_policy: ReferrerPolicy,
124    /// Permissions-Policy header (default: None)
125    pub permissions_policy: Option<String>,
126    /// Cache-Control header for sensitive content (default: None)
127    pub cache_control: Option<String>,
128}
129
130impl Default for SecurityHeaders {
131    /// Creates security headers with sensible defaults.
132    ///
133    /// # Default Values
134    /// - `X-Content-Type-Options: nosniff`
135    /// - `X-Frame-Options: DENY`
136    /// - `X-XSS-Protection: 0` (disabled as recommended)
137    /// - `Referrer-Policy: strict-origin-when-cross-origin`
138    fn default() -> Self {
139        SecurityHeaders {
140            content_type_options: true,
141            frame_options: FrameOptions::Deny,
142            xss_protection: false, // XSS Auditor is deprecated
143            content_security_policy: None,
144            hsts_enabled: false,
145            hsts_max_age: 31536000, // 1 year
146            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    /// Creates a new security headers configuration with defaults.
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Creates a strict security headers configuration.
162    ///
163    /// Enables all security headers with strict values.
164    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    /// Sets the X-Frame-Options header.
181    ///
182    /// # Spring Security Equivalent
183    /// `headers().frameOptions().deny()` or `.sameOrigin()`
184    pub fn frame_options(mut self, options: FrameOptions) -> Self {
185        self.frame_options = options;
186        self
187    }
188
189    /// Sets the Content-Security-Policy header.
190    ///
191    /// # Spring Security Equivalent
192    /// `headers().contentSecurityPolicy("policy")`
193    ///
194    /// # Example
195    /// ```ignore
196    /// let headers = SecurityHeaders::new()
197    ///     .content_security_policy("default-src 'self'; script-src 'self' 'unsafe-inline'");
198    /// ```
199    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    /// Enables HTTP Strict Transport Security (HSTS).
205    ///
206    /// # Spring Security Equivalent
207    /// `headers().httpStrictTransportSecurity()`
208    ///
209    /// # Arguments
210    /// * `enabled` - Whether to enable HSTS
211    /// * `max_age` - Max-age value in seconds
212    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    /// Sets HSTS to include subdomains.
219    pub fn hsts_include_subdomains(mut self, include: bool) -> Self {
220        self.hsts_include_subdomains = include;
221        self
222    }
223
224    /// Sets HSTS preload flag.
225    ///
226    /// # Warning
227    /// Only enable this if you've submitted your domain to the HSTS preload list.
228    pub fn hsts_preload(mut self, preload: bool) -> Self {
229        self.hsts_preload = preload;
230        self
231    }
232
233    /// Sets the Referrer-Policy header.
234    ///
235    /// # Spring Security Equivalent
236    /// `headers().referrerPolicy(ReferrerPolicy.STRICT_ORIGIN)`
237    pub fn referrer_policy(mut self, policy: ReferrerPolicy) -> Self {
238        self.referrer_policy = policy;
239        self
240    }
241
242    /// Sets the Permissions-Policy header.
243    ///
244    /// # Example
245    /// ```ignore
246    /// let headers = SecurityHeaders::new()
247    ///     .permissions_policy("geolocation=(), microphone=(), camera=()");
248    /// ```
249    pub fn permissions_policy(mut self, policy: impl Into<String>) -> Self {
250        self.permissions_policy = Some(policy.into());
251        self
252    }
253
254    /// Sets the Cache-Control header for sensitive content.
255    pub fn cache_control(mut self, value: impl Into<String>) -> Self {
256        self.cache_control = Some(value.into());
257        self
258    }
259
260    /// Disables X-Content-Type-Options header.
261    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
297/// Security headers middleware service.
298pub 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            // X-Content-Type-Options
326            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            // X-Frame-Options
334            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            // X-XSS-Protection (disabled by default, set to 0)
342            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            // Content-Security-Policy
352            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            // Strict-Transport-Security (HSTS)
359            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            // Referrer-Policy
367            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            // Permissions-Policy
375            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            // Cache-Control
382            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    // =============================================================================
398    // FrameOptions Tests
399    // =============================================================================
400
401    #[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    // =============================================================================
426    // ReferrerPolicy Tests
427    // =============================================================================
428
429    #[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    // =============================================================================
464    // SecurityHeaders Default Tests
465    // =============================================================================
466
467    #[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    // =============================================================================
498    // SecurityHeaders Strict Tests
499    // =============================================================================
500
501    #[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    // =============================================================================
521    // Builder Pattern Tests
522    // =============================================================================
523
524    #[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    // =============================================================================
616    // HSTS Value Building Tests
617    // =============================================================================
618
619    #[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    // =============================================================================
661    // Clone Tests
662    // =============================================================================
663
664    #[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    // =============================================================================
680    // Debug Tests
681    // =============================================================================
682
683    #[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}