Skip to main content

datasynth_server/rest/
security_headers.rs

1//! Security headers middleware.
2//!
3//! Injects security-related response headers on all responses.
4
5use axum::{
6    body::Body,
7    http::{Request, Response},
8    middleware::Next,
9};
10
11/// Security headers middleware.
12///
13/// Adds the following headers to all responses:
14/// - `X-Content-Type-Options: nosniff`
15/// - `X-Frame-Options: DENY`
16/// - `X-XSS-Protection: 0` (modern best practice - rely on CSP instead)
17/// - `Referrer-Policy: strict-origin-when-cross-origin`
18/// - `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`
19/// - `Cache-Control: no-store` (API responses should not be cached)
20pub async fn security_headers_middleware(request: Request<Body>, next: Next) -> Response<Body> {
21    let mut response = next.run(request).await;
22    let headers = response.headers_mut();
23
24    headers.insert("x-content-type-options", "nosniff".parse().unwrap());
25    headers.insert("x-frame-options", "DENY".parse().unwrap());
26    headers.insert("x-xss-protection", "0".parse().unwrap());
27    headers.insert(
28        "referrer-policy",
29        "strict-origin-when-cross-origin".parse().unwrap(),
30    );
31    headers.insert(
32        "content-security-policy",
33        "default-src 'none'; frame-ancestors 'none'"
34            .parse()
35            .unwrap(),
36    );
37    headers.insert("cache-control", "no-store".parse().unwrap());
38
39    response
40}
41
42#[cfg(test)]
43#[allow(clippy::unwrap_used)]
44mod tests {
45    use super::*;
46    use axum::{routing::get, Router};
47    use tower::ServiceExt;
48
49    async fn ok_handler() -> &'static str {
50        "ok"
51    }
52
53    fn test_router() -> Router {
54        Router::new()
55            .route("/test", get(ok_handler))
56            .layer(axum::middleware::from_fn(security_headers_middleware))
57    }
58
59    #[tokio::test]
60    async fn test_security_headers_present() {
61        let router = test_router();
62        let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
63
64        let response = router.oneshot(request).await.unwrap();
65        let headers = response.headers();
66
67        assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
68        assert_eq!(headers.get("x-frame-options").unwrap(), "DENY");
69        assert_eq!(headers.get("x-xss-protection").unwrap(), "0");
70        assert_eq!(
71            headers.get("referrer-policy").unwrap(),
72            "strict-origin-when-cross-origin"
73        );
74        assert_eq!(
75            headers.get("content-security-policy").unwrap(),
76            "default-src 'none'; frame-ancestors 'none'"
77        );
78        assert_eq!(headers.get("cache-control").unwrap(), "no-store");
79    }
80}