Skip to main content

arcly_http/web/
security.rs

1//! Security headers middleware -- a Helmet.js equivalent for arcly-http.
2//!
3//! ## Quick start
4//!
5//! The default configuration applies the recommended OWASP header set
6//! automatically. To customise, call [`configure`] once at server startup
7//! (before `App::launch`):
8//!
9//! ```rust,ignore
10//! use arcly_http::security::{configure, SecurityConfig, FrameOptions};
11//!
12//! security::configure(SecurityConfig {
13//!     hsts_max_age: 0,                           // disable HSTS in dev
14//!     frame_options: FrameOptions::SameOrigin,
15//!     ..SecurityConfig::default()
16//! });
17//! ```
18//!
19//! ## Headers emitted (defaults)
20//!
21//! | Header                      | Default value                            |
22//! |-----------------------------|------------------------------------------|
23//! | `Strict-Transport-Security` | `max-age=31536000; includeSubDomains; preload` |
24//! | `X-Frame-Options`           | `DENY`                                   |
25//! | `X-Content-Type-Options`    | `nosniff`                                |
26//! | `X-XSS-Protection`          | `1; mode=block`                          |
27//! | `Referrer-Policy`           | `strict-origin-when-cross-origin`        |
28//! | `Permissions-Policy`        | restrictive: camera, mic, geo, payment   |
29//! | `Content-Security-Policy`   | `default-src 'self'` (relaxed for /docs) |
30
31use std::sync::OnceLock;
32
33use axum::{extract::Request, http::HeaderValue, middleware::Next, response::Response};
34
35// ─── Configuration types ─────────────────────────────────────────────────────
36
37/// Controls what `X-Frame-Options` header is emitted.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FrameOptions {
40    /// `DENY` -- recommended for most APIs.
41    Deny,
42    /// `SAMEORIGIN` -- allows embedding in same-origin frames.
43    SameOrigin,
44    /// Omit the header entirely (e.g. for public media CDNs).
45    Disabled,
46}
47
48/// Full security header configuration.
49///
50/// Every field has a production-safe default. Override only what your
51/// deployment requires. See module docs for the default value table.
52#[derive(Debug, Clone)]
53pub struct SecurityConfig {
54    /// HSTS `max-age` in seconds. Set `0` to disable the header (e.g. in local
55    /// dev where TLS is not in use). Default: `31_536_000` (1 year).
56    pub hsts_max_age: u64,
57    /// Include `includeSubDomains` directive. Default: `true`.
58    pub hsts_include_subdomains: bool,
59    /// Include `preload` directive (required for HSTS preload submission).
60    /// Default: `true`.
61    pub hsts_preload: bool,
62    /// `X-Frame-Options` policy. Default: [`FrameOptions::Deny`].
63    pub frame_options: FrameOptions,
64    /// `Referrer-Policy` value. Must be a `'static` str.
65    /// Default: `"strict-origin-when-cross-origin"`.
66    pub referrer_policy: &'static str,
67    /// `Permissions-Policy` value. `None` omits the header.
68    /// Default: restrictive policy disabling camera, microphone, geolocation,
69    /// payment, and USB.
70    pub permissions_policy: Option<&'static str>,
71    /// CSP for all routes except `/docs`. Default: `"default-src 'self'"`.
72    pub csp_default: &'static str,
73    /// CSP override for the `/docs` Swagger UI route. `None` uses `csp_default`.
74    /// Default: relaxes `script-src` and `style-src` for the unpkg CDN.
75    pub csp_docs_override: Option<&'static str>,
76    /// Enable `X-Content-Type-Options: nosniff`. Default: `true`.
77    pub nosniff: bool,
78    /// Enable `X-XSS-Protection: 1; mode=block`. Default: `true`.
79    pub xss_protection: bool,
80}
81
82impl Default for SecurityConfig {
83    fn default() -> Self {
84        Self {
85            hsts_max_age: 31_536_000,
86            hsts_include_subdomains: true,
87            hsts_preload: true,
88            frame_options: FrameOptions::Deny,
89            referrer_policy: "strict-origin-when-cross-origin",
90            permissions_policy: Some(
91                "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
92            ),
93            csp_default: "default-src 'self'",
94            csp_docs_override: Some(concat!(
95                "default-src 'self'; ",
96                "script-src 'self' 'unsafe-inline' https://unpkg.com; ",
97                "style-src 'self' 'unsafe-inline' https://unpkg.com; ",
98                "img-src 'self' data: https://unpkg.com; ",
99                "font-src 'self' data: https://unpkg.com; ",
100                "connect-src 'self'",
101            )),
102            nosniff: true,
103            xss_protection: true,
104        }
105    }
106}
107
108// ─── Global config store ─────────────────────────────────────────────────────
109
110static CONFIG: OnceLock<SecurityConfig> = OnceLock::new();
111
112/// Install a custom security configuration.
113///
114/// **Must be called before `App::launch`**. The first call wins; subsequent
115/// calls are silently ignored (safe to call from multiple threads at startup).
116pub fn configure(cfg: SecurityConfig) {
117    let _ = CONFIG.set(cfg);
118}
119
120#[inline]
121fn cfg() -> &'static SecurityConfig {
122    CONFIG.get_or_init(SecurityConfig::default)
123}
124
125// ─── Axum middleware ─────────────────────────────────────────────────────────
126
127/// Apply all configured security headers to every response.
128///
129/// Registered automatically by `App::launch` as the outermost layer.
130pub async fn apply_security_headers(req: Request, next: Next) -> Response {
131    let is_docs = req.uri().path() == "/docs";
132    let mut resp = next.run(req).await;
133    let c = cfg();
134    let h = resp.headers_mut();
135
136    // Strict-Transport-Security
137    if c.hsts_max_age > 0 {
138        let mut val = format!("max-age={}", c.hsts_max_age);
139        if c.hsts_include_subdomains {
140            val.push_str("; includeSubDomains");
141        }
142        if c.hsts_preload {
143            val.push_str("; preload");
144        }
145        if let Ok(v) = HeaderValue::from_str(&val) {
146            h.insert("Strict-Transport-Security", v);
147        }
148    }
149
150    // X-Frame-Options
151    match c.frame_options {
152        FrameOptions::Deny => {
153            h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
154        }
155        FrameOptions::SameOrigin => {
156            h.insert("X-Frame-Options", HeaderValue::from_static("SAMEORIGIN"));
157        }
158        FrameOptions::Disabled => {}
159    }
160
161    if c.nosniff {
162        h.insert(
163            "X-Content-Type-Options",
164            HeaderValue::from_static("nosniff"),
165        );
166    }
167    if c.xss_protection {
168        h.insert(
169            "X-XSS-Protection",
170            HeaderValue::from_static("1; mode=block"),
171        );
172    }
173    h.insert(
174        "Referrer-Policy",
175        HeaderValue::from_static(c.referrer_policy),
176    );
177
178    if let Some(pp) = c.permissions_policy {
179        h.insert("Permissions-Policy", HeaderValue::from_static(pp));
180    }
181
182    let csp = if is_docs {
183        c.csp_docs_override.unwrap_or(c.csp_default)
184    } else {
185        c.csp_default
186    };
187    h.insert("Content-Security-Policy", HeaderValue::from_static(csp));
188
189    resp
190}