Skip to main content

tako_rs_plugins/middleware/
security_headers.rs

1//! Security headers middleware.
2//!
3//! Emits a curated set of HTTP response headers that close the most common
4//! injection / framing / cross-origin gaps. Defaults match modern browser
5//! advice (OWASP Secure Headers Project, web.dev / MDN guidance):
6//!
7//! - `X-Content-Type-Options: nosniff`
8//! - `X-Frame-Options: DENY`
9//! - `Referrer-Policy: strict-origin-when-cross-origin`
10//! - `Strict-Transport-Security` (opt-in via [`SecurityHeaders::hsts`])
11//! - `Content-Security-Policy` (opt-in via [`SecurityHeaders::csp`] /
12//!   [`SecurityHeaders::csp_with_nonce`])
13//! - `Cross-Origin-Opener-Policy: same-origin` (opt-in)
14//! - `Cross-Origin-Embedder-Policy: require-corp` (opt-in)
15//! - `Cross-Origin-Resource-Policy: same-origin` (opt-in)
16//! - `Permissions-Policy` (opt-in)
17//!
18//! `X-XSS-Protection` is intentionally **not** emitted — modern browsers
19//! ignore the header and OWASP recommends removing it. CSP is the
20//! authoritative replacement.
21//!
22//! Per-request CSP nonces are exposed as a [`CspNonce`] extension so handlers
23//! can interpolate them into inline `<script>` / `<style>` blocks. The header
24//! emitted to the client substitutes the nonce into a template string.
25
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29
30use http::HeaderValue;
31use tako_rs_core::middleware::IntoMiddleware;
32use tako_rs_core::middleware::Next;
33use tako_rs_core::types::Request;
34use tako_rs_core::types::Response;
35
36/// Per-request CSP nonce inserted into request extensions when
37/// [`SecurityHeaders::csp_with_nonce`] is configured.
38///
39/// Handlers can substitute the value into inline scripts:
40///
41/// ```rust,ignore
42/// let nonce = req.extensions().get::<CspNonce>().expect("CSP middleware mounted").0.clone();
43/// let html = format!(r#"<script nonce="{nonce}">…</script>"#);
44/// ```
45#[derive(Debug, Clone)]
46pub struct CspNonce(pub String);
47
48#[derive(Clone)]
49enum CspMode {
50  Static(HeaderValue),
51  WithNonce { template: String, header: bool },
52}
53
54/// Security headers middleware configuration.
55pub struct SecurityHeaders {
56  frame_options: HeaderValue,
57  hsts: bool,
58  hsts_max_age: u64,
59  hsts_include_subdomains: bool,
60  hsts_preload: bool,
61  referrer_policy: HeaderValue,
62  csp: Option<CspMode>,
63  coop: Option<HeaderValue>,
64  coep: Option<HeaderValue>,
65  corp: Option<HeaderValue>,
66  permissions_policy: Option<HeaderValue>,
67}
68
69impl Default for SecurityHeaders {
70  fn default() -> Self {
71    Self::new()
72  }
73}
74
75impl SecurityHeaders {
76  /// Creates a `SecurityHeaders` middleware with sensible defaults.
77  pub fn new() -> Self {
78    Self {
79      frame_options: HeaderValue::from_static("DENY"),
80      hsts: false,
81      hsts_max_age: 31_536_000,
82      hsts_include_subdomains: true,
83      hsts_preload: false,
84      referrer_policy: HeaderValue::from_static("strict-origin-when-cross-origin"),
85      csp: None,
86      coop: None,
87      coep: None,
88      corp: None,
89      permissions_policy: None,
90    }
91  }
92
93  /// Sets the `X-Frame-Options` value (e.g. `"DENY"`, `"SAMEORIGIN"`).
94  pub fn frame_options(mut self, value: &'static str) -> Self {
95    self.frame_options = HeaderValue::from_static(value);
96    self
97  }
98
99  /// Enables / disables `Strict-Transport-Security`.
100  pub fn hsts(mut self, enable: bool) -> Self {
101    self.hsts = enable;
102    self
103  }
104
105  /// Sets the HSTS `max-age`. Default: 1 year.
106  pub fn hsts_max_age(mut self, seconds: u64) -> Self {
107    self.hsts_max_age = seconds;
108    self
109  }
110
111  /// Toggles `includeSubDomains` on HSTS. Default: true.
112  pub fn hsts_include_subdomains(mut self, on: bool) -> Self {
113    self.hsts_include_subdomains = on;
114    self
115  }
116
117  /// Toggles `preload` on HSTS. Default: false. Submission to the HSTS
118  /// preload list requires `max-age >= 31536000` and `includeSubDomains`.
119  pub fn hsts_preload(mut self, on: bool) -> Self {
120    self.hsts_preload = on;
121    self
122  }
123
124  /// Sets the `Referrer-Policy` value.
125  pub fn referrer_policy(mut self, value: &'static str) -> Self {
126    self.referrer_policy = HeaderValue::from_static(value);
127    self
128  }
129
130  /// Emits a static `Content-Security-Policy` header.
131  pub fn csp(mut self, value: &'static str) -> Self {
132    self.csp = Some(CspMode::Static(HeaderValue::from_static(value)));
133    self
134  }
135
136  /// Emits a per-request CSP with a fresh nonce. The template must contain
137  /// the literal substring `{nonce}`, which is replaced before emission. The
138  /// generated nonce is also inserted into request extensions as
139  /// [`CspNonce`].
140  pub fn csp_with_nonce(mut self, template: impl Into<String>) -> Self {
141    self.csp = Some(CspMode::WithNonce {
142      template: template.into(),
143      header: false,
144    });
145    self
146  }
147
148  /// Same as [`Self::csp_with_nonce`], but emit `Content-Security-Policy-Report-Only`.
149  pub fn csp_report_only(mut self, template: impl Into<String>) -> Self {
150    self.csp = Some(CspMode::WithNonce {
151      template: template.into(),
152      header: true,
153    });
154    self
155  }
156
157  /// Sets `Cross-Origin-Opener-Policy`.
158  pub fn coop(mut self, value: &'static str) -> Self {
159    self.coop = Some(HeaderValue::from_static(value));
160    self
161  }
162
163  /// Sets `Cross-Origin-Embedder-Policy`.
164  pub fn coep(mut self, value: &'static str) -> Self {
165    self.coep = Some(HeaderValue::from_static(value));
166    self
167  }
168
169  /// Sets `Cross-Origin-Resource-Policy`.
170  pub fn corp(mut self, value: &'static str) -> Self {
171    self.corp = Some(HeaderValue::from_static(value));
172    self
173  }
174
175  /// Sets `Permissions-Policy`.
176  pub fn permissions_policy(mut self, value: &'static str) -> Self {
177    self.permissions_policy = Some(HeaderValue::from_static(value));
178    self
179  }
180}
181
182fn rand_nonce() -> String {
183  // 18 random bytes → 24-char base64. UUID v4 covers 16 random bytes; pad
184  // with the second half of a fresh UUID for the remaining 2.
185  use base64::Engine;
186
187  let u1 = uuid::Uuid::new_v4().into_bytes();
188  let u2 = uuid::Uuid::new_v4().into_bytes();
189  let mut buf = [0u8; 18];
190  buf[..16].copy_from_slice(&u1);
191  buf[16..].copy_from_slice(&u2[..2]);
192  base64::engine::general_purpose::STANDARD_NO_PAD.encode(buf)
193}
194
195impl IntoMiddleware for SecurityHeaders {
196  fn into_middleware(
197    self,
198  ) -> impl Fn(Request, Next) -> Pin<Box<dyn Future<Output = Response> + Send + 'static>>
199  + Clone
200  + Send
201  + Sync
202  + 'static {
203    let frame_options = self.frame_options;
204    let hsts_value = if self.hsts {
205      let mut buf = format!("max-age={}", self.hsts_max_age);
206      if self.hsts_include_subdomains {
207        buf.push_str("; includeSubDomains");
208      }
209      if self.hsts_preload {
210        buf.push_str("; preload");
211      }
212      Some(HeaderValue::from_str(&buf).expect("valid HSTS header"))
213    } else {
214      None
215    };
216    let referrer_policy = self.referrer_policy;
217    let csp = Arc::new(self.csp);
218    let coop = self.coop;
219    let coep = self.coep;
220    let corp = self.corp;
221    let permissions_policy = self.permissions_policy;
222
223    move |mut req: Request, next: Next| {
224      let frame_options = frame_options.clone();
225      let hsts_value = hsts_value.clone();
226      let referrer_policy = referrer_policy.clone();
227      let csp = csp.clone();
228      let coop = coop.clone();
229      let coep = coep.clone();
230      let corp = corp.clone();
231      let permissions_policy = permissions_policy.clone();
232
233      Box::pin(async move {
234        // Generate the per-request nonce up front so the handler can read it
235        // back from request extensions before the response is built.
236        let prepared_csp: Option<(HeaderValue, bool)> = match csp.as_ref() {
237          None => None,
238          Some(CspMode::Static(v)) => Some((v.clone(), false)),
239          Some(CspMode::WithNonce { template, header }) => {
240            let nonce = rand_nonce();
241            let value = template.replace("{nonce}", &nonce);
242            req.extensions_mut().insert(CspNonce(nonce));
243            HeaderValue::from_str(&value).ok().map(|hv| (hv, *header))
244          }
245        };
246
247        let mut resp = next.run(req).await;
248        let headers = resp.headers_mut();
249
250        headers.insert(
251          "x-content-type-options",
252          HeaderValue::from_static("nosniff"),
253        );
254        headers.insert("x-frame-options", frame_options);
255        headers.insert("referrer-policy", referrer_policy);
256
257        if let Some(hsts) = hsts_value {
258          headers.insert("strict-transport-security", hsts);
259        }
260
261        if let Some((v, report_only)) = prepared_csp {
262          let key = if report_only {
263            "content-security-policy-report-only"
264          } else {
265            "content-security-policy"
266          };
267          headers.insert(key, v);
268        }
269
270        if let Some(v) = coop {
271          headers.insert("cross-origin-opener-policy", v);
272        }
273        if let Some(v) = coep {
274          headers.insert("cross-origin-embedder-policy", v);
275        }
276        if let Some(v) = corp {
277          headers.insert("cross-origin-resource-policy", v);
278        }
279        if let Some(v) = permissions_policy {
280          headers.insert("permissions-policy", v);
281        }
282
283        resp
284      })
285    }
286  }
287}