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}