Skip to main content

forma_server/
csp.rs

1//! CSP nonce generation and policy construction.
2
3use base64::{engine::general_purpose::STANDARD, Engine as _};
4use ring::rand::{SecureRandom, SystemRandom};
5use std::fmt;
6
7/// A validated CSP nonce.
8///
9/// The nonce is interpolated **unescaped** into HTML attributes
10/// (`nonce="{nonce}"` on every script/style tag) and raw into the CSP header
11/// value. That is only safe because the value comes from
12/// [`CspNonce::generate`], whose base64 alphabet contains nothing that is
13/// attribute- or directive-significant. The newtype exists so a caller that
14/// supplies its own nonce — which cache-aware callers must, see
15/// [`crate::template::render_page_with_nonce`] — cannot hand in
16/// `" onload="alert(1)` (attribute breakout on every script tag) or
17/// `x' 'unsafe-inline` (rewriting the policy from outside).
18///
19/// It also makes `generate()` the obvious path, which blunts the other failure
20/// mode: a caller hard-coding one static, publicly-known nonce for all users
21/// defeats nonce-based CSP entirely.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CspNonce(String);
24
25/// Returned by [`CspNonce::parse`] for a value that is not safe to interpolate.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InvalidNonce;
28
29impl fmt::Display for InvalidNonce {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(
32            f,
33            "invalid CSP nonce: must be at least 22 characters of [A-Za-z0-9+/=_-]"
34        )
35    }
36}
37
38impl std::error::Error for InvalidNonce {}
39
40/// Minimum accepted nonce length — 22 base64 characters is 128 bits, the
41/// floor the CSP spec recommends for unguessability.
42const MIN_NONCE_LEN: usize = 22;
43
44impl CspNonce {
45    /// Generate a cryptographically random nonce (32 bytes → 44 base64 chars).
46    pub fn generate() -> Self {
47        let rng = SystemRandom::new();
48        let mut bytes = [0u8; 32];
49        rng.fill(&mut bytes).expect("RNG failure");
50        CspNonce(STANDARD.encode(bytes))
51    }
52
53    /// Validate a caller-supplied nonce.
54    ///
55    /// Accepts the base64 and base64url alphabets only; anything that could
56    /// break out of an HTML attribute or a CSP directive is rejected.
57    pub fn parse(value: &str) -> Result<Self, InvalidNonce> {
58        if value.len() < MIN_NONCE_LEN {
59            return Err(InvalidNonce);
60        }
61        if !value
62            .bytes()
63            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-'))
64        {
65            return Err(InvalidNonce);
66        }
67        Ok(CspNonce(value.to_string()))
68    }
69
70    /// The nonce value, for use in a `nonce="..."` attribute.
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76impl fmt::Display for CspNonce {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(&self.0)
79    }
80}
81
82/// Generate a cryptographically random CSP nonce (base64, 44 chars).
83///
84/// Alias for [`CspNonce::generate`].
85pub fn generate_csp_nonce() -> CspNonce {
86    CspNonce::generate()
87}
88
89/// Build a strict Content-Security-Policy header value.
90///
91/// Policy notes, because "strict CSP" is easy to claim and easy to get wrong:
92///
93/// * `script-src` carries `'strict-dynamic'`. Without it a browser keeps
94///   honouring the `'self'` host-source alongside the nonce, so any
95///   same-origin URL that reflects attacker-controlled bytes (a JSON API, an
96///   uploaded file, an error page) can be loaded as `<script src>` and the
97///   nonce buys nothing. `'self'` is retained for engines that do not
98///   understand `'strict-dynamic'` — they ignore the unknown keyword and fall
99///   back to today's behavior.
100/// * `worker-src 'self'` is explicit because `'strict-dynamic'` does not cover
101///   workers. The inheritance chain is worker-src → child-src → script-src →
102///   default-src, so the service worker used to load via script-src's
103///   `'self'`; neutralizing `'self'` therefore requires stating it here.
104/// * `style-src-attr 'unsafe-inline'` permits inline `style=""` ATTRIBUTES.
105///   Per CSP, a nonce in `style-src` drops `unsafe-inline` semantics, and a
106///   nonce cannot be attached to an attribute — so the nonce-only policy
107///   silently broke every component compiled with a `style:` binding, i.e. the
108///   framework's own compiler output. `style-src-elem` still falls back to the
109///   nonce-locked `style-src`, so `<style>` blocks and `<link rel=stylesheet>`
110///   stay strict.
111///
112///   Browser support: `style-src-attr` is CSP3 (Chrome/Edge 75+, Firefox 74+,
113///   Safari 15.4+). Older engines ignore the unknown directive and fall back
114///   to `style-src`, so attribute styles stay blocked there — a fail-closed
115///   degradation to the previous behavior, not a security regression.
116/// * `object-src 'none'` is explicit defence in depth: it inherits from
117///   `default-src 'none'` today, but that stops being true the moment
118///   `default-src` is loosened.
119pub fn build_csp_header(nonce: &CspNonce) -> String {
120    let nonce = nonce.as_str();
121    format!(
122        "default-src 'none'; \
123         script-src 'nonce-{nonce}' 'strict-dynamic' 'self'; \
124         style-src 'nonce-{nonce}' 'self'; \
125         style-src-attr 'unsafe-inline'; \
126         worker-src 'self'; \
127         object-src 'none'; \
128         connect-src 'self'; \
129         img-src 'self' data:; \
130         font-src 'self'; \
131         frame-ancestors 'none'; \
132         base-uri 'none'; \
133         form-action 'self'"
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// Extract one directive's value from a policy string.
142    fn directive<'a>(csp: &'a str, name: &str) -> Option<&'a str> {
143        csp.split(';')
144            .map(str::trim)
145            .find_map(|d| d.strip_prefix(name).filter(|rest| rest.starts_with(' ')))
146            .map(str::trim)
147    }
148
149    #[test]
150    fn nonce_is_unique() {
151        let a = generate_csp_nonce();
152        let b = generate_csp_nonce();
153        assert_ne!(a, b);
154    }
155
156    #[test]
157    fn nonce_is_base64() {
158        let nonce = generate_csp_nonce();
159        assert_eq!(nonce.as_str().len(), 44); // 32 bytes -> 44 base64 chars
160        assert!(STANDARD.decode(nonce.as_str()).is_ok());
161    }
162
163    #[test]
164    fn csp_contains_nonce() {
165        let nonce = generate_csp_nonce();
166        let csp = build_csp_header(&nonce);
167        assert!(csp.contains(&format!("'nonce-{nonce}'")));
168    }
169
170    #[test]
171    fn parse_rejects_attribute_and_directive_breakout() {
172        // These are exactly the payloads the newtype exists to stop: the first
173        // breaks out of nonce="..." on every script tag, the second rewrites
174        // the policy into an unsafe-inline one from outside.
175        for bad in [
176            "\" onload=\"alert(1)",
177            "x' 'unsafe-inline",
178            "short",
179            "has spaces in it here",
180            "semi;colon;value;padding",
181        ] {
182            assert_eq!(CspNonce::parse(bad), Err(InvalidNonce), "accepted {bad:?}");
183        }
184    }
185
186    #[test]
187    fn parse_accepts_a_generated_nonce() {
188        let generated = CspNonce::generate();
189        assert_eq!(CspNonce::parse(generated.as_str()), Ok(generated));
190    }
191
192    #[test]
193    fn csp_is_strict() {
194        let csp = build_csp_header(&CspNonce::parse("test-nonce-value-0123456789").unwrap());
195
196        assert_eq!(directive(&csp, "default-src"), Some("'none'"));
197
198        // Scripts: nonce + strict-dynamic, never unsafe-*.
199        let script = directive(&csp, "script-src").expect("script-src");
200        assert!(script.contains("'nonce-"));
201        assert!(
202            script.contains("'strict-dynamic'"),
203            "without strict-dynamic the 'self' host-source is still honoured"
204        );
205        assert!(!script.contains("unsafe-inline"));
206        assert!(!script.contains("unsafe-eval"));
207
208        // Style ELEMENTS stay nonce-locked...
209        let style = directive(&csp, "style-src").expect("style-src");
210        assert!(style.contains("'nonce-"));
211        assert!(!style.contains("unsafe-inline"));
212
213        // ...while style ATTRIBUTES are permitted, because compiled `style:`
214        // bindings emit them.
215        assert_eq!(directive(&csp, "style-src-attr"), Some("'unsafe-inline'"));
216        // No style-src-elem override: the fallback to nonce-locked style-src
217        // is what keeps <style> blocks strict.
218        assert_eq!(directive(&csp, "style-src-elem"), None);
219
220        // strict-dynamic does not cover workers, so this must be explicit.
221        assert_eq!(directive(&csp, "worker-src"), Some("'self'"));
222        assert_eq!(directive(&csp, "object-src"), Some("'none'"));
223    }
224}