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}