Skip to main content

rullst_core/security/
headers.rs

1//! Secure response headers and per-request Content Security Policy nonces.
2
3use axum::{
4    extract::Request,
5    http::{HeaderValue, header},
6    middleware::Next,
7    response::Response,
8};
9use base64::{Engine, engine::general_purpose::STANDARD};
10use rand::Rng;
11
12pub use crate::config::DEFAULT_CSP_TEMPLATE;
13
14const DEFAULT_STATIC_CSP: &str = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; worker-src 'self' blob:";
15
16/// A cryptographically random CSP nonce associated with one request.
17///
18/// Handlers and renderers can extract this value with `Extension<CspNonce>` and add
19/// `nonce="..."` to trusted inline `<script>` or `<style>` elements.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct CspNonce(String);
22
23impl CspNonce {
24    /// Generates a fresh 128-bit nonce using the operating system random source.
25    pub fn generate() -> Self {
26        let mut bytes = [0_u8; 16];
27        rand::rng().fill_bytes(&mut bytes);
28        Self(STANDARD.encode(bytes))
29    }
30
31    /// Returns the nonce value for an HTML `nonce` attribute.
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Returns the nonce already associated with a request or creates and stores a fresh one.
37    ///
38    /// Security layers should use this helper instead of unconditionally replacing the request
39    /// extension so independently composed layers and the renderer share one nonce identity.
40    pub fn get_or_insert(extensions: &mut axum::http::Extensions) -> Self {
41        if let Some(nonce) = extensions.get::<Self>() {
42            return nonce.clone();
43        }
44
45        let nonce = Self::generate();
46        extensions.insert(nonce.clone());
47        nonce
48    }
49}
50
51impl std::fmt::Display for CspNonce {
52    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        formatter.write_str(self.as_str())
54    }
55}
56
57/// Renders a CSP template against an optional request nonce.
58///
59/// A template containing `{NONCE}` cannot be emitted without a nonce; in that case the strict
60/// static policy is used instead of sending a broken or permissive directive.
61pub fn render_csp_policy(template: Option<&str>, nonce: Option<&CspNonce>) -> String {
62    let template = template.filter(|value| !value.trim().is_empty());
63    match (template, nonce) {
64        (Some(template), Some(nonce)) => template.replace("{NONCE}", nonce.as_str()),
65        (Some(template), None) if !template.contains("{NONCE}") => template.to_owned(),
66        (None, Some(nonce)) => DEFAULT_CSP_TEMPLATE.replace("{NONCE}", nonce.as_str()),
67        _ => DEFAULT_STATIC_CSP.to_owned(),
68    }
69}
70
71/// Middleware that injects strict security headers and exposes a matching CSP nonce to handlers.
72pub async fn headers_middleware(mut req: Request, next: Next) -> Response {
73    let configured_csp = req
74        .extensions()
75        .get::<crate::config::SecurityConfig>()
76        .map(|config| config.csp.clone())
77        .unwrap_or_else(|| crate::config::RullstConfig::global().security.csp.clone());
78    let configured_coep = req
79        .extensions()
80        .get::<crate::config::SecurityConfig>()
81        .map(|config| config.coep.clone())
82        .unwrap_or_else(|| crate::config::RullstConfig::global().security.coep.clone());
83    // Reuse a nonce installed by an outer security layer. Generated applications and
84    // integrations may compose more than one header layer; replacing the request nonce here
85    // would make the renderer use a different value from the final CSP response header.
86    let nonce = CspNonce::get_or_insert(req.extensions_mut());
87
88    let mut response = next.run(req).await;
89    let headers = response.headers_mut();
90
91    // Dynamic responses are private by default. A handler can still opt into an explicit,
92    // reviewed cache policy for public/static content before this middleware runs.
93    headers
94        .entry(header::CACHE_CONTROL)
95        .or_insert(HeaderValue::from_static("no-store"));
96    headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
97    headers.insert(
98        header::X_CONTENT_TYPE_OPTIONS,
99        HeaderValue::from_static("nosniff"),
100    );
101    // The legacy XSS auditor has caused response mutation vulnerabilities in old browsers.
102    headers.insert("x-xss-protection", HeaderValue::from_static("0"));
103    headers.insert(
104        header::REFERRER_POLICY,
105        HeaderValue::from_static("strict-origin-when-cross-origin"),
106    );
107    headers.insert(
108        header::STRICT_TRANSPORT_SECURITY,
109        HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"),
110    );
111    headers.insert(
112        "permissions-policy",
113        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
114    );
115    headers.insert(
116        "cross-origin-opener-policy",
117        HeaderValue::from_static("same-origin"),
118    );
119    headers.insert(
120        "cross-origin-resource-policy",
121        HeaderValue::from_static("same-origin"),
122    );
123    headers.insert(
124        "cross-origin-embedder-policy",
125        HeaderValue::from_str(&configured_coep)
126            .unwrap_or_else(|_| HeaderValue::from_static("require-corp")),
127    );
128
129    let csp = render_csp_policy(Some(&configured_csp), Some(&nonce));
130    let csp_value = HeaderValue::from_str(&csp).unwrap_or_else(|_| {
131        HeaderValue::from_str(&render_csp_policy(None, Some(&nonce)))
132            .unwrap_or_else(|_| HeaderValue::from_static("default-src 'none'"))
133    });
134    headers.insert(header::CONTENT_SECURITY_POLICY, csp_value);
135
136    response
137}