Skip to main content

impulse_server_kit/
security_headers.rs

1//! Conservative security response headers.
2//!
3//! When [`crate::startup::get_root_router_autoinject`] is used, every
4//! response gets a default set of security headers unless the operator
5//! opts out via the YAML config (`security_headers:` block).
6//!
7//! Defaults:
8//!
9//! | Header                       | Value                                  |
10//! |------------------------------|----------------------------------------|
11//! | `Strict-Transport-Security`  | `max-age=31536000; includeSubDomains`  |
12//! | `X-Content-Type-Options`     | `nosniff`                              |
13//! | `X-Frame-Options`            | `SAMEORIGIN`                           |
14//! | `Referrer-Policy`            | `strict-origin-when-cross-origin`      |
15//!
16//! `Content-Security-Policy`, `Permissions-Policy` and
17//! `Cross-Origin-Opener-Policy` are off by default because they are
18//! highly application-specific.
19//!
20//! Set a field to `null` in YAML to disable a specific header without
21//! turning the whole hoop off:
22//!
23//! ```yaml
24//! security_headers:
25//!   hsts: null  # acceptable for `http_localhost` dev setups
26//! ```
27//!
28//! Whatever the YAML says, HSTS is *also* skipped automatically when
29//! the server starts in `http_localhost` or `unsafe_http` mode — sending
30//! `Strict-Transport-Security` over plain HTTP would pin localhost in
31//! the developer's browser for a year. Headers already present on the
32//! response are left untouched, so per-route overrides win.
33
34use std::sync::Arc;
35
36use salvo::http::HeaderValue;
37use salvo::http::header::{
38  CONTENT_SECURITY_POLICY, HeaderName, REFERRER_POLICY, STRICT_TRANSPORT_SECURITY, X_CONTENT_TYPE_OPTIONS,
39  X_FRAME_OPTIONS,
40};
41use salvo::prelude::*;
42use serde::Deserialize;
43
44use crate::setup::{GenericServerState, StartupVariant};
45
46/// YAML-configurable values for the security headers hoop.
47///
48/// All fields are populated with sensible defaults via [`Default`];
49/// `#[serde(default)]` on the struct means a missing block in YAML
50/// produces the same defaults. Explicit `null` for any optional field
51/// disables that header.
52#[derive(Clone, Debug, Deserialize)]
53#[serde(default)]
54pub struct SecurityHeadersOptions {
55  /// Master switch. Set to `false` to skip the hoop entirely.
56  pub enabled: bool,
57  /// `Strict-Transport-Security`. Defaults to one year + subdomains.
58  /// Forced off when the listener is HTTP-only (see module docs).
59  pub hsts: Option<String>,
60  /// `X-Content-Type-Options`. Default `nosniff`.
61  pub x_content_type_options: Option<String>,
62  /// `X-Frame-Options`. Default `SAMEORIGIN`.
63  pub x_frame_options: Option<String>,
64  /// `Referrer-Policy`. Default `strict-origin-when-cross-origin`.
65  pub referrer_policy: Option<String>,
66  /// `Content-Security-Policy`. Off by default — apps must opt in.
67  pub content_security_policy: Option<String>,
68  /// `Permissions-Policy`. Off by default.
69  pub permissions_policy: Option<String>,
70  /// `Cross-Origin-Opener-Policy`. Off by default.
71  pub cross_origin_opener_policy: Option<String>,
72}
73
74impl Default for SecurityHeadersOptions {
75  fn default() -> Self {
76    Self {
77      enabled: true,
78      hsts: Some("max-age=31536000; includeSubDomains".to_string()),
79      x_content_type_options: Some("nosniff".to_string()),
80      x_frame_options: Some("SAMEORIGIN".to_string()),
81      referrer_policy: Some("strict-origin-when-cross-origin".to_string()),
82      content_security_policy: None,
83      permissions_policy: None,
84      cross_origin_opener_policy: None,
85    }
86  }
87}
88
89/// Salvo hoop that injects the configured security headers into every
90/// response. Build via [`SecurityHeaders::new`] and attach with
91/// `router.hoop(...)`.
92pub struct SecurityHeaders {
93  always: Arc<Vec<(HeaderName, HeaderValue)>>,
94  hsts: Option<(HeaderName, HeaderValue)>,
95}
96
97fn parse(name: HeaderName, value: &Option<String>) -> Option<(HeaderName, HeaderValue)> {
98  let raw = value.as_deref()?;
99  match HeaderValue::from_str(raw) {
100    Ok(v) => Some((name, v)),
101    Err(e) => {
102      tracing::warn!(error = %e, header = %name, raw = %raw, "ignoring invalid security header value");
103      None
104    }
105  }
106}
107
108impl SecurityHeaders {
109  /// Build a hoop from `options`. Invalid header values are logged and
110  /// skipped — they never block startup.
111  pub fn new(options: &SecurityHeadersOptions) -> Self {
112    let mut always = Vec::new();
113    always.extend(parse(X_CONTENT_TYPE_OPTIONS, &options.x_content_type_options));
114    always.extend(parse(X_FRAME_OPTIONS, &options.x_frame_options));
115    always.extend(parse(REFERRER_POLICY, &options.referrer_policy));
116    always.extend(parse(CONTENT_SECURITY_POLICY, &options.content_security_policy));
117    always.extend(parse(
118      HeaderName::from_static("permissions-policy"),
119      &options.permissions_policy,
120    ));
121    always.extend(parse(
122      HeaderName::from_static("cross-origin-opener-policy"),
123      &options.cross_origin_opener_policy,
124    ));
125    let hsts = parse(STRICT_TRANSPORT_SECURITY, &options.hsts);
126    Self {
127      always: Arc::new(always),
128      hsts,
129    }
130  }
131
132  fn apply(&self, hsts_allowed: bool, headers: &mut salvo::http::HeaderMap) {
133    for (name, value) in self.always.iter() {
134      if !headers.contains_key(name) {
135        headers.insert(name.clone(), value.clone());
136      }
137    }
138    if hsts_allowed
139      && let Some((name, value)) = &self.hsts
140      && !headers.contains_key(name)
141    {
142      headers.insert(name.clone(), value.clone());
143    }
144  }
145}
146
147#[salvo::async_trait]
148impl Handler for SecurityHeaders {
149  async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) {
150    ctrl.call_next(req, depot, res).await;
151
152    // HSTS over plain HTTP is wrong (RFC 6797) and pinning `127.0.0.1`
153    // for a year is a footgun developers hit on first run; trust the
154    // startup variant when available.
155    let hsts_allowed = depot
156      .obtain::<GenericServerState>()
157      .map(|s| {
158        !matches!(
159          s.startup_variant,
160          StartupVariant::HttpLocalhost | StartupVariant::UnsafeHttp
161        )
162      })
163      .unwrap_or(true);
164
165    self.apply(hsts_allowed, res.headers_mut());
166  }
167}
168
169#[cfg(test)]
170mod tests {
171  use super::*;
172  use salvo::http::HeaderMap;
173
174  fn build(opts: SecurityHeadersOptions) -> SecurityHeaders {
175    SecurityHeaders::new(&opts)
176  }
177
178  #[test]
179  fn defaults_emit_the_four_baseline_headers() {
180    let mut headers = HeaderMap::new();
181    build(SecurityHeadersOptions::default()).apply(true, &mut headers);
182
183    assert_eq!(headers["x-content-type-options"], "nosniff");
184    assert_eq!(headers["x-frame-options"], "SAMEORIGIN");
185    assert_eq!(headers["referrer-policy"], "strict-origin-when-cross-origin");
186    assert_eq!(
187      headers["strict-transport-security"],
188      "max-age=31536000; includeSubDomains"
189    );
190    assert!(!headers.contains_key("content-security-policy"));
191    assert!(!headers.contains_key("permissions-policy"));
192  }
193
194  #[test]
195  fn skips_hsts_over_plain_http() {
196    let mut headers = HeaderMap::new();
197    build(SecurityHeadersOptions::default()).apply(false, &mut headers);
198    assert!(!headers.contains_key("strict-transport-security"));
199    // Other headers still go through.
200    assert_eq!(headers["x-content-type-options"], "nosniff");
201  }
202
203  #[test]
204  fn null_disables_specific_header() {
205    let opts = SecurityHeadersOptions {
206      x_frame_options: None,
207      ..Default::default()
208    };
209    let mut headers = HeaderMap::new();
210    build(opts).apply(true, &mut headers);
211    assert!(!headers.contains_key("x-frame-options"));
212    assert_eq!(headers["x-content-type-options"], "nosniff");
213  }
214
215  #[test]
216  fn does_not_overwrite_existing_headers() {
217    let mut headers = HeaderMap::new();
218    headers.insert("x-frame-options", "DENY".parse().unwrap());
219    build(SecurityHeadersOptions::default()).apply(true, &mut headers);
220    // Per-route override wins.
221    assert_eq!(headers["x-frame-options"], "DENY");
222  }
223
224  #[test]
225  fn optional_headers_appear_when_set() {
226    let opts = SecurityHeadersOptions {
227      content_security_policy: Some("default-src 'self'".to_string()),
228      permissions_policy: Some("camera=()".to_string()),
229      cross_origin_opener_policy: Some("same-origin".to_string()),
230      ..Default::default()
231    };
232    let mut headers = HeaderMap::new();
233    build(opts).apply(true, &mut headers);
234    assert_eq!(headers["content-security-policy"], "default-src 'self'");
235    assert_eq!(headers["permissions-policy"], "camera=()");
236    assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
237  }
238
239  #[test]
240  fn invalid_value_is_dropped_not_panicked() {
241    let opts = SecurityHeadersOptions {
242      x_frame_options: Some("bad\nvalue".to_string()),
243      ..Default::default()
244    };
245    let mut headers = HeaderMap::new();
246    build(opts).apply(true, &mut headers);
247    // The bad value is dropped; the rest still works.
248    assert!(!headers.contains_key("x-frame-options"));
249    assert_eq!(headers["x-content-type-options"], "nosniff");
250  }
251}