koprogo_api/infrastructure/web/
security_headers.rs

1use actix_web::{
2    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
3    Error,
4};
5use futures_util::future::LocalBoxFuture;
6use std::future::{ready, Ready};
7
8/// Security headers middleware for production-ready security
9///
10/// Adds the following security headers to all responses:
11/// - Strict-Transport-Security (HSTS): Force HTTPS
12/// - X-Content-Type-Options: Prevent MIME sniffing
13/// - X-Frame-Options: Prevent clickjacking
14/// - X-XSS-Protection: Enable browser XSS filter
15/// - Content-Security-Policy: Restrict resource loading
16/// - Referrer-Policy: Control referrer information
17/// - Permissions-Policy: Control browser features
18pub struct SecurityHeaders;
19
20impl<S, B> Transform<S, ServiceRequest> for SecurityHeaders
21where
22    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
23    S::Future: 'static,
24    B: 'static,
25{
26    type Response = ServiceResponse<B>;
27    type Error = Error;
28    type InitError = ();
29    type Transform = SecurityHeadersMiddleware<S>;
30    type Future = Ready<Result<Self::Transform, Self::InitError>>;
31
32    fn new_transform(&self, service: S) -> Self::Future {
33        ready(Ok(SecurityHeadersMiddleware { service }))
34    }
35}
36
37pub struct SecurityHeadersMiddleware<S> {
38    service: S,
39}
40
41impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddleware<S>
42where
43    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
44    S::Future: 'static,
45    B: 'static,
46{
47    type Response = ServiceResponse<B>;
48    type Error = Error;
49    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
50
51    forward_ready!(service);
52
53    fn call(&self, req: ServiceRequest) -> Self::Future {
54        let fut = self.service.call(req);
55
56        Box::pin(async move {
57            let mut res = fut.await?;
58
59            let headers = res.headers_mut();
60
61            // HSTS: Force HTTPS for 1 year (31536000 seconds)
62            // includeSubDomains: Apply to all subdomains
63            // preload: Submit to HSTS preload list
64            headers.insert(
65                actix_web::http::header::HeaderName::from_static("strict-transport-security"),
66                actix_web::http::header::HeaderValue::from_static(
67                    "max-age=31536000; includeSubDomains; preload",
68                ),
69            );
70
71            // Prevent MIME type sniffing
72            headers.insert(
73                actix_web::http::header::HeaderName::from_static("x-content-type-options"),
74                actix_web::http::header::HeaderValue::from_static("nosniff"),
75            );
76
77            // Prevent clickjacking attacks
78            headers.insert(
79                actix_web::http::header::HeaderName::from_static("x-frame-options"),
80                actix_web::http::header::HeaderValue::from_static("DENY"),
81            );
82
83            // Enable browser XSS protection (legacy, but still useful)
84            headers.insert(
85                actix_web::http::header::HeaderName::from_static("x-xss-protection"),
86                actix_web::http::header::HeaderValue::from_static("1; mode=block"),
87            );
88
89            // Content Security Policy (CSP)
90            // - default-src 'self': Only load resources from same origin
91            // - script-src 'self' 'unsafe-inline': Allow inline scripts (needed for frontend frameworks)
92            // - style-src 'self' 'unsafe-inline': Allow inline styles
93            // - img-src 'self' data: https:: Allow images from same origin, data URLs, and HTTPS
94            // - font-src 'self': Only load fonts from same origin
95            // - connect-src 'self': Only connect to same origin APIs
96            // - frame-ancestors 'none': Prevent framing (same as X-Frame-Options)
97            // - base-uri 'self': Prevent base tag hijacking
98            // - form-action 'self': Only submit forms to same origin
99            headers.insert(
100                actix_web::http::header::HeaderName::from_static("content-security-policy"),
101                actix_web::http::header::HeaderValue::from_static(
102                    "default-src 'self'; \
103                     script-src 'self' 'unsafe-inline' 'unsafe-eval'; \
104                     style-src 'self' 'unsafe-inline'; \
105                     img-src 'self' data: https:; \
106                     font-src 'self' data:; \
107                     connect-src 'self'; \
108                     frame-ancestors 'none'; \
109                     base-uri 'self'; \
110                     form-action 'self'",
111                ),
112            );
113
114            // Referrer Policy: Don't send referrer to cross-origin requests
115            headers.insert(
116                actix_web::http::header::HeaderName::from_static("referrer-policy"),
117                actix_web::http::header::HeaderValue::from_static(
118                    "strict-origin-when-cross-origin",
119                ),
120            );
121
122            // Permissions Policy (formerly Feature-Policy)
123            // Disable potentially dangerous browser features
124            headers.insert(
125                actix_web::http::header::HeaderName::from_static("permissions-policy"),
126                actix_web::http::header::HeaderValue::from_static(
127                    "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
128                ),
129            );
130
131            Ok(res)
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use actix_web::{test, web, App, HttpResponse};
140
141    #[actix_web::test]
142    async fn test_security_headers_are_added() {
143        let app = test::init_service(App::new().wrap(SecurityHeaders).route(
144            "/test",
145            web::get().to(|| async { HttpResponse::Ok().finish() }),
146        ))
147        .await;
148
149        let req = test::TestRequest::get().uri("/test").to_request();
150        let resp = test::call_service(&app, req).await;
151
152        // Verify HSTS header
153        assert!(resp.headers().contains_key("strict-transport-security"));
154        assert_eq!(
155            resp.headers().get("strict-transport-security").unwrap(),
156            "max-age=31536000; includeSubDomains; preload"
157        );
158
159        // Verify X-Content-Type-Options
160        assert!(resp.headers().contains_key("x-content-type-options"));
161        assert_eq!(
162            resp.headers().get("x-content-type-options").unwrap(),
163            "nosniff"
164        );
165
166        // Verify X-Frame-Options
167        assert!(resp.headers().contains_key("x-frame-options"));
168        assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
169
170        // Verify CSP
171        assert!(resp.headers().contains_key("content-security-policy"));
172
173        // Verify Referrer-Policy
174        assert!(resp.headers().contains_key("referrer-policy"));
175
176        // Verify Permissions-Policy
177        assert!(resp.headers().contains_key("permissions-policy"));
178    }
179}