Skip to main content

ferro_rs/middleware/
security_headers.rs

1//! Security headers middleware
2//!
3//! Adds OWASP-recommended security headers to all responses.
4//! Provides sensible defaults that work for both Inertia.js and JSON-UI apps
5//! without breaking development workflows.
6//!
7//! Reference: <https://owasp.org/www-project-secure-headers/>
8
9use crate::http::{HttpResponse, Request, Response};
10use crate::middleware::{Middleware, Next};
11use async_trait::async_trait;
12
13/// Middleware that adds security headers to every response.
14///
15/// Ships OWASP-recommended defaults out of the box. Each header can be
16/// overridden or disabled via the builder API.
17///
18/// HSTS is **off by default** because it breaks `localhost` over HTTP.
19/// Call [`with_hsts`](Self::with_hsts) to enable it in production.
20///
21/// # Example
22///
23/// ```rust,ignore
24/// use ferro::SecurityHeaders;
25///
26/// // Use defaults (safe for development)
27/// global_middleware!(SecurityHeaders::new());
28///
29/// // Production: enable HSTS
30/// global_middleware!(SecurityHeaders::new().with_hsts());
31///
32/// // Custom overrides
33/// global_middleware!(
34///     SecurityHeaders::new()
35///         .x_frame_options("SAMEORIGIN")
36///         .without("Permissions-Policy")
37/// );
38/// ```
39pub 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    /// Create with OWASP-recommended defaults.
52    ///
53    /// All headers except HSTS are enabled. HSTS is off by default
54    /// to avoid breaking development over HTTP.
55    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    /// Enable HSTS with `max-age=31536000; includeSubDomains` (no preload).
78    ///
79    /// Safe for production use. Does not include `preload` because preload
80    /// submission is permanent and affects all subdomains.
81    pub fn with_hsts(mut self) -> Self {
82        self.strict_transport_security = Some("max-age=31536000; includeSubDomains".to_string());
83        self
84    }
85
86    /// Enable HSTS with `preload` directive.
87    ///
88    /// Only use this if you intend to submit your domain to the HSTS preload
89    /// list. Preload is permanent — removing a domain takes months.
90    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    /// Disable HSTS (same as default, for explicitness).
97    pub fn without_hsts(mut self) -> Self {
98        self.strict_transport_security = None;
99        self
100    }
101
102    /// Override the X-Frame-Options header value.
103    ///
104    /// Default is `DENY`. Use `SAMEORIGIN` to allow framing from the same origin.
105    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    /// Override the Content-Security-Policy header value.
111    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    /// Override the Referrer-Policy header value.
117    pub fn referrer_policy(mut self, value: impl Into<String>) -> Self {
118        self.referrer_policy = Some(value.into());
119        self
120    }
121
122    /// Override the Permissions-Policy header value.
123    pub fn permissions_policy(mut self, value: impl Into<String>) -> Self {
124        self.permissions_policy = Some(value.into());
125        self
126    }
127
128    /// Override the Cross-Origin-Opener-Policy header value.
129    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    /// Disable a specific header by name.
135    ///
136    /// The name is matched case-insensitively.
137    ///
138    /// # Example
139    ///
140    /// ```rust,ignore
141    /// SecurityHeaders::new()
142    ///     .without("X-Frame-Options")
143    ///     .without("Permissions-Policy");
144    /// ```
145    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    /// Apply all enabled headers to a response.
161    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        // Other headers remain set
294        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        // All defaults still set
311        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        // HSTS should not be present by default
352        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        // Others still present
383        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}