forma_server/csp.rs
1//! CSP nonce generation and policy construction: the enforced policy, its
2//! typed extensions, and enforced Trusted Types with a named route relaxation.
3
4use base64::{engine::general_purpose::STANDARD, Engine as _};
5use ring::rand::{SecureRandom, SystemRandom};
6use std::fmt;
7
8/// A validated CSP nonce.
9///
10/// The nonce is interpolated **unescaped** into HTML attributes
11/// (`nonce="{nonce}"` on every script/style tag) and raw into the CSP header
12/// value. That is only safe because the value comes from
13/// [`CspNonce::generate`], whose base64 alphabet contains nothing that is
14/// attribute- or directive-significant. Parsing an existing nonce is useful for
15/// diagnostics and policy inspection. Public page renderers always generate a
16/// fresh nonce themselves; no renderer accepts a caller-supplied nonce.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CspNonce(String);
19
20/// Returned by [`CspNonce::parse`] for a value that is not safe to interpolate.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct InvalidNonce;
23
24impl fmt::Display for InvalidNonce {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 write!(
27 f,
28 "invalid CSP nonce: must be at least 22 characters of [A-Za-z0-9+/=_-]"
29 )
30 }
31}
32
33impl std::error::Error for InvalidNonce {}
34
35/// Returned by [`CspNonce::generate`] when the operating system's random
36/// source fails: there is no nonce, so there is no page.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub struct NonceError;
40
41impl fmt::Display for NonceError {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 write!(f, "the system random source failed to draw a CSP nonce")
44 }
45}
46
47impl std::error::Error for NonceError {}
48
49/// Minimum accepted nonce length — 22 base64 characters is 128 bits, the
50/// floor the CSP spec recommends for unguessability.
51const MIN_NONCE_LEN: usize = 22;
52
53impl CspNonce {
54 /// Draw a cryptographically random nonce (32 bytes → 44 base64 chars), or
55 /// return [`NonceError`] if the system random source fails. The render path
56 /// carries a failed draw as a [`crate::RenderError`].
57 pub fn generate() -> Result<Self, NonceError> {
58 Self::generate_with(|bytes| SystemRandom::new().fill(bytes).map_err(|_| NonceError))
59 }
60
61 // ring's SecureRandom is sealed. Keep the draw injectable privately so
62 // refusal can be tested without changing the operating system's RNG.
63 fn generate_with(
64 draw: impl FnOnce(&mut [u8; 32]) -> Result<(), NonceError>,
65 ) -> Result<Self, NonceError> {
66 let mut bytes = [0u8; 32];
67 draw(&mut bytes)?;
68 Ok(CspNonce(STANDARD.encode(bytes)))
69 }
70
71 /// Compatibility alias for [`CspNonce::generate`], with the same fallible result.
72 pub fn try_generate() -> Result<Self, NonceError> {
73 Self::generate()
74 }
75
76 /// Validate a caller-supplied nonce.
77 ///
78 /// Accepts the base64 and base64url alphabets only; anything that could
79 /// break out of an HTML attribute or a CSP directive is rejected.
80 pub fn parse(value: &str) -> Result<Self, InvalidNonce> {
81 if value.len() < MIN_NONCE_LEN {
82 return Err(InvalidNonce);
83 }
84 if !value
85 .bytes()
86 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'_' | b'-'))
87 {
88 return Err(InvalidNonce);
89 }
90 Ok(CspNonce(value.to_string()))
91 }
92
93 /// The nonce value, for use in a `nonce="..."` attribute.
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99impl fmt::Display for CspNonce {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.write_str(&self.0)
102 }
103}
104
105/// Generate a cryptographically random CSP nonce (base64, 44 chars).
106///
107/// Alias for [`CspNonce::generate`].
108pub fn generate_csp_nonce() -> Result<CspNonce, NonceError> {
109 CspNonce::generate()
110}
111
112/// The strict policy's directives, in the order [`build_csp_header`] writes
113/// them, for a nonce. One table, so the plain header and an extended one can
114/// never disagree about the base.
115fn strict_directives(nonce: &str) -> [(&'static str, Vec<String>); 14] {
116 let nonce_source = format!("'nonce-{nonce}'");
117 let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
118 [
119 ("default-src", words(&["'none'"])),
120 (
121 "script-src",
122 vec![
123 nonce_source.clone(),
124 "'strict-dynamic'".to_string(),
125 "'self'".to_string(),
126 ],
127 ),
128 ("style-src", vec![nonce_source, "'self'".to_string()]),
129 ("style-src-attr", words(&["'unsafe-inline'"])),
130 ("worker-src", words(&["'self'"])),
131 ("object-src", words(&["'none'"])),
132 ("connect-src", words(&["'self'"])),
133 ("img-src", words(&["'self'", "data:"])),
134 ("font-src", words(&["'self'"])),
135 ("frame-ancestors", words(&["'none'"])),
136 ("base-uri", words(&["'none'"])),
137 ("form-action", words(&["'self'"])),
138 ("require-trusted-types-for", words(&["'script'"])),
139 ("trusted-types", words(&["forma", "forma-runtime"])),
140 ]
141}
142
143/// Write a policy from its directives: `name source source; name ...`.
144fn write_policy<'a>(directives: impl IntoIterator<Item = (&'a str, &'a [String])>) -> String {
145 let mut out = String::with_capacity(512);
146 for (name, sources) in directives {
147 if !out.is_empty() {
148 out.push_str("; ");
149 }
150 out.push_str(name);
151 for source in sources {
152 out.push(' ');
153 out.push_str(source);
154 }
155 }
156 out
157}
158
159/// Build a strict Content-Security-Policy header value.
160///
161/// Policy notes, because "strict CSP" is easy to claim and easy to get wrong:
162///
163/// * `script-src` carries `'strict-dynamic'`. Without it a browser keeps
164/// honouring the `'self'` host-source alongside the nonce, so any
165/// same-origin URL that reflects attacker-controlled bytes (a JSON API, an
166/// uploaded file, an error page) can be loaded as `<script src>` and the
167/// nonce buys nothing. `'self'` is retained for engines that do not
168/// understand `'strict-dynamic'` — they ignore the unknown keyword and fall
169/// back to today's behavior.
170/// * `worker-src 'self'` is explicit because `'strict-dynamic'` does not cover
171/// workers. The inheritance chain is worker-src → child-src → script-src →
172/// default-src, so the service worker used to load via script-src's
173/// `'self'`; neutralizing `'self'` therefore requires stating it here.
174/// * `style-src-attr 'unsafe-inline'` permits inline `style=""` ATTRIBUTES.
175/// Per CSP, a nonce in `style-src` drops `unsafe-inline` semantics, and a
176/// nonce cannot be attached to an attribute — so the nonce-only policy
177/// silently broke every component compiled with a `style:` binding, i.e. the
178/// framework's own compiler output. `style-src-elem` still falls back to the
179/// nonce-locked `style-src`, so `<style>` blocks and `<link rel=stylesheet>`
180/// stay strict.
181///
182/// Browser support: `style-src-attr` is CSP3 (Chrome/Edge 75+, Firefox 74+,
183/// Safari 15.4+). Older engines ignore the unknown directive and fall back
184/// to `style-src`, so attribute styles stay blocked there — a fail-closed
185/// degradation to the previous behavior, not a security regression.
186/// * `object-src 'none'` is explicit defence in depth: it inherits from
187/// `default-src 'none'` today, but that stops being true the moment
188/// `default-src` is loosened.
189pub fn build_csp_header(nonce: &CspNonce) -> String {
190 let directives = strict_directives(nonce.as_str());
191 write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())))
192}
193
194// ---------------------------------------------------------------------------
195// Enforced Trusted Types, optional reporting, and the reporting endpoint
196// ---------------------------------------------------------------------------
197
198/// The Trusted Types directives: every write to a script-carrying
199/// DOM sink must pass a Trusted Type, and only Forma's two policies may make
200/// one: `forma` (`trustedHtml`, the raw entry) and `forma-runtime` (the
201/// zero-build runtime's parser). No `'allow-duplicates'`: a second copy of
202/// core creating `forma` is itself a violation.
203///
204/// These directives are enforced on every strict document, including frames,
205/// in debug and release builds. A named per-route relaxation removes only these
206/// directives from enforcement and retains them in Report-Only.
207const TRUSTED_TYPES: [(&str, &[&str]); 2] = [
208 ("require-trusted-types-for", &["'script'"]),
209 ("trusted-types", &["forma", "forma-runtime"]),
210];
211
212/// The reporting group a named endpoint gets in `Reporting-Endpoints`, and
213/// the `report-to` directive's value.
214pub(crate) const REPORTING_GROUP: &str = "forma-csp";
215
216/// Where a page's violations go, and how they are asked for.
217#[derive(Debug, Clone, PartialEq, Eq)]
218enum Reporting {
219 /// `report-to` with `Reporting-Endpoints`, and `report-uri`
220 /// ([`PolicyExtensions::report_to`]).
221 Both(String),
222 /// `report-uri` alone ([`PolicyExtensions::report_uri`]).
223 UriOnly(String),
224}
225
226impl Reporting {
227 fn endpoint(&self) -> &str {
228 match self {
229 Reporting::Both(endpoint) | Reporting::UriOnly(endpoint) => endpoint,
230 }
231 }
232
233 /// The method that named it, for a refusal.
234 fn method(&self) -> &'static str {
235 match self {
236 Reporting::Both(_) => "report-to",
237 Reporting::UriOnly(_) => "report-uri",
238 }
239 }
240
241 /// The reporting directives each policy carries.
242 fn directives(&self) -> Vec<(&'static str, Vec<String>)> {
243 let uri = ("report-uri", vec![self.endpoint().to_string()]);
244 match self {
245 Reporting::Both(_) => vec![("report-to", vec![REPORTING_GROUP.to_string()]), uri],
246 Reporting::UriOnly(_) => vec![uri],
247 }
248 }
249}
250
251/// The Report-Only policy: the Trusted Types directives, then the reporting
252/// directives when an endpoint is named.
253fn report_only_text(reporting: Option<&Reporting>) -> String {
254 let mut directives: Vec<(&str, Vec<String>)> = TRUSTED_TYPES
255 .iter()
256 .map(|(name, sources)| (*name, sources.iter().map(|s| s.to_string()).collect()))
257 .collect();
258 if let Some(reporting) = reporting {
259 directives.extend(reporting.directives());
260 }
261 write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())))
262}
263
264/// The Report-Only policy a page without a reporting endpoint carries: in a
265/// development build (`cfg(debug_assertions)`) the Trusted Types directives,
266/// whose violations Chromium and Firefox show in the console, which is what
267/// moving the sinks needs; in a release build none. Released, it would
268/// report to nobody, and WebKit logs a console error on every page whose
269/// Report-Only policy names no `report-to` ("the policy will have no
270/// effect"), which a test failing on console errors in WebKit then meets
271/// (FORMA-138's packed-set run).
272///
273/// Verified by: crates/forma-server/tests/trusted_types_report_only.rs > "trusted_types_go_out_in_report_only_with_an_endpoint_or_in_a_debug_build_and_the_enforced_policy_is_unchanged"
274pub(crate) fn report_only_policy() -> Option<String> {
275 cfg!(debug_assertions).then(|| report_only_text(None))
276}
277
278// ---------------------------------------------------------------------------
279// Typed extensions (DR-17, L-38)
280// ---------------------------------------------------------------------------
281
282/// What a handler may add to a page's policy, and nothing else.
283///
284/// The strict policy ([`build_csp_header`]) is the floor. A page that needs
285/// another image or frame source, a form target, or a login popup adds it
286/// here; it cannot remove a directive, loosen `script-src` or `style-src`,
287/// or write the policy as text. The same builder names the application's
288/// reporting endpoint ([`PolicyExtensions::report_to`]) and relaxes Trusted
289/// Types on a route ([`PolicyExtensions::relax_trusted_types`]). A [`crate::PageResponse`] applies the
290/// extensions when it is made, and an invalid one fails the page with a
291/// [`PolicyError`] instead of reaching a header.
292///
293/// Each source must be one CSP source expression: `'self'`, a scheme
294/// (`https:`, `blob:`, `data:`, `wss:`), or a host (`cdn.example.com`,
295/// `https://*.example.com:8443/images/`). Nonces, hashes, `'none'`,
296/// `'unsafe-*'`, `'strict-dynamic'`, a bare `*`, and anything carrying `;`,
297/// `,`, a quote or whitespace are refused: each of those either loosens script
298/// execution or injects a directive. So are the `javascript` and `vbscript`
299/// schemes in either form (`javascript:`, `javascript://host`), and `data:` and
300/// `filesystem:` as a frame or form target.
301///
302/// ```
303/// use forma_server::PolicyExtensions;
304///
305/// // A console showing pasted logo URLs and a same-origin preview frame.
306/// let console = PolicyExtensions::new().img_src("https:").frame_src("'self'");
307/// // A login page posting to an identity provider in a popup.
308/// let login = PolicyExtensions::new()
309/// .form_action("https://idp.example.com")
310/// .allow_popups();
311/// # let _ = (console, login);
312/// ```
313///
314/// Verified by: crates/forma-server/tests/internal_renderer/page_response.rs > "no_policy_extension_yields_a_response_without_the_policy"
315#[derive(Debug, Clone, Default, PartialEq, Eq)]
316pub struct PolicyExtensions {
317 img_src: Vec<String>,
318 frame_src: Vec<String>,
319 form_action: Vec<String>,
320 allow_popups: bool,
321 reporting: Option<Reporting>,
322 relax_trusted_types: Option<String>,
323}
324
325impl PolicyExtensions {
326 /// No extensions: the strict policy and `Cross-Origin-Opener-Policy:
327 /// same-origin`.
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 /// Allow images from `source` too (`img-src` keeps `'self' data:`).
333 #[must_use]
334 pub fn img_src(mut self, source: impl Into<String>) -> Self {
335 self.img_src.push(source.into());
336 self
337 }
338
339 /// Allow frames from `source`. The strict policy names no `frame-src`, so
340 /// frames fall back to `default-src 'none'`; the first extension adds the
341 /// directive.
342 #[must_use]
343 pub fn frame_src(mut self, source: impl Into<String>) -> Self {
344 self.frame_src.push(source.into());
345 self
346 }
347
348 /// Allow forms on this page to submit to `source` too (`form-action`
349 /// keeps `'self'`).
350 #[must_use]
351 pub fn form_action(mut self, source: impl Into<String>) -> Self {
352 self.form_action.push(source.into());
353 self
354 }
355
356 /// Send `Cross-Origin-Opener-Policy: same-origin-allow-popups` instead of
357 /// `same-origin`, so a popup this page opens (an OAuth login) keeps its
358 /// `window.opener`. The page itself stays isolated from its own opener.
359 #[must_use]
360 pub fn allow_popups(mut self) -> Self {
361 self.allow_popups = true;
362 self
363 }
364
365 /// Send this page's policy violations to `endpoint`, an endpoint the
366 /// application runs, for pages served over HTTPS. Name it
367 /// once, on the `PolicyExtensions` every route starts from, so no page
368 /// leaves without it.
369 ///
370 /// `endpoint` is a path on the application's own origin (`/csp-reports`,
371 /// the usual choice), an `https://` URL, or, in a development build only
372 /// (`cfg(debug_assertions)`), an `http://` URL on `localhost`,
373 /// `127.0.0.1` or `[::1]`: deployed, a loopback endpoint would send each
374 /// visitor's reports to the visitor's own machine, so a release build
375 /// refuses it like any invalid endpoint. An absolute URL's authority is
376 /// a host (letters, digits, `.` and `-`, or a bracketed IPv6 address) and
377 /// an optional port of digits, never userinfo: a credential written there
378 /// would go to every visitor. It is written into both policies,
379 /// the enforced one and the Trusted Types Report-Only one, twice over:
380 ///
381 /// - `report-to forma-csp`, with a `Reporting-Endpoints: forma-csp="<endpoint>"`
382 /// header: the Reporting API (Chrome and Edge 96+, Safari 16.4+, Firefox
383 /// 149+ for CSP), which POSTs `application/reports+json` batches, an
384 /// array of `{"type": "csp-violation", "body": {..}}`;
385 /// - `report-uri <endpoint>`, for engines without `report-to` for CSP
386 /// (Firefox before 149), which POST one `application/csp-report`
387 /// document per violation. An engine with `report-to` ignores it.
388 ///
389 /// So the endpoint accepts both bodies. A cross-origin endpoint must also
390 /// answer CORS preflights; a same-origin path needs none. Browsers send
391 /// reports outside the page, so the policy's `connect-src` does not apply.
392 ///
393 /// **Over plain HTTP, `http://localhost` included, use
394 /// [`PolicyExtensions::report_uri`].** Chrome and Firefox take
395 /// `Reporting-Endpoints` only from an HTTPS page, and once a policy names
396 /// `report-to` they ignore `report-uri`, so a page served over plain HTTP
397 /// with both reports nothing to the endpoint. That holds on
398 /// `http://localhost` too, although it is a secure context, so in
399 /// development name the endpoint with `report_uri`. Measured with Chrome
400 /// 155 and 156 and Firefox Nightly 155 (this PR and its review): the same
401 /// page and endpoint over HTTP, on `localhost` or another host, sent no
402 /// report; over HTTPS each sent one `application/reports+json` report and
403 /// no `report-uri` copy.
404 ///
405 /// **The endpoint's obligations are the application's:**
406 ///
407 /// - Store each report's URLs (`documentURL` / `document-uri`,
408 /// `blockedURL` / `blocked-uri`, `referrer`, `sourceFile`) **without
409 /// their query strings or fragments**: they can carry tokens and
410 /// personal data. A Trusted Types report's `sample` / `script-sample`
411 /// holds the first characters of the value written to the sink, so
412 /// treat it as personal data too.
413 /// - Bound each report's size (refuse a body over, say, 64 KiB) and the
414 /// rate it accepts (per client and in total): anyone can POST to it.
415 /// - Alert **per route and directive above a baseline**, deduplicated:
416 /// never a ticket per report. One page in a loop sends thousands.
417 ///
418 /// Trusted Types enforcement does not depend on an endpoint. The additional
419 /// diagnostic Report-Only header goes out in development builds and whenever
420 /// a route explicitly relaxes enforcement. Naming an endpoint sends
421 /// it in every build. An endpoint that is empty, carries `;`,
422 /// `,`, a quote, a backslash, whitespace, a fragment or a non-ASCII byte,
423 /// starts with `//`, or uses another scheme fails the page with a
424 /// [`PolicyError`] for `report-to`. The last of `report_to` and
425 /// `report_uri` called is the one written.
426 ///
427 /// ```
428 /// use forma_server::PolicyExtensions;
429 ///
430 /// // At start: every route's policy starts from this one.
431 /// let base = PolicyExtensions::new().report_to("/csp-reports");
432 /// // A route that also needs a frame source.
433 /// let preview = base.clone().frame_src("'self'");
434 /// # let _ = preview;
435 /// ```
436 ///
437 /// Verified by: crates/forma-server/tests/trusted_types_report_only.rs > "a_named_endpoint_is_written_into_both_policies_and_reporting_endpoints"
438 #[must_use]
439 pub fn report_to(mut self, endpoint: impl Into<String>) -> Self {
440 self.reporting = Some(Reporting::Both(endpoint.into()));
441 self
442 }
443
444 /// Send this page's policy violations to `endpoint` through `report-uri`
445 /// alone, for pages served over plain HTTP: a development server on
446 /// `http://localhost`, a CI run. Every current engine sends a
447 /// `report-uri` report from an HTTP page (one `application/csp-report`
448 /// POST per violation), where Chrome and Firefox send none for
449 /// [`PolicyExtensions::report_to`]'s pair, on `http://localhost` too. The
450 /// same endpoint rules and obligations apply; a refusal is a
451 /// [`PolicyError`] for `report-uri`.
452 ///
453 /// ```
454 /// use forma_server::PolicyExtensions;
455 ///
456 /// let served_over_https = false; // the application's own setting
457 /// let base = if served_over_https {
458 /// PolicyExtensions::new().report_to("/csp-reports")
459 /// } else {
460 /// PolicyExtensions::new().report_uri("/csp-reports")
461 /// };
462 /// # let _ = base;
463 /// ```
464 ///
465 /// Verified by: crates/forma-server/tests/trusted_types_report_only.rs > "a_named_endpoint_is_written_into_both_policies_and_reporting_endpoints"
466 #[must_use]
467 pub fn report_uri(mut self, endpoint: impl Into<String>) -> Self {
468 self.reporting = Some(Reporting::UriOnly(endpoint.into()));
469 self
470 }
471
472 /// Relax Trusted Types on this route, for a page whose string sinks have
473 /// not moved to `trustedHtml` yet (the bridge to 3.0.0's enforcement). An
474 /// allowlisted exit: each call belongs on the application's raw-entry
475 /// allowlist (`forma-raw-lint`) with an owner and an end date, and
476 /// `reason` should name them.
477 ///
478 /// The selected route omits the two Trusted Types enforcement directives,
479 /// retaining them in Report-Only even without a reporting endpoint. Other
480 /// routes keep enforcement. Where a reporting endpoint is configured,
481 /// its violations are still reported, so the sinks left to move stay
482 /// visible. A relaxed route is logged, once per route and reason per
483 /// process, as a `tracing` warning naming the route and `reason`;
484 /// `route_pattern` must be the route's pattern (`/users/:id`), not a
485 /// request's path, and the log stops naming new pairs past 1,024. An
486 /// empty `reason` fails the page with a [`PolicyError`] for
487 /// `trusted-types`.
488 ///
489 /// ```
490 /// use forma_server::PolicyExtensions;
491 ///
492 /// let studio = PolicyExtensions::new()
493 /// .report_to("/csp-reports")
494 /// .relax_trusted_types("app.ts writes innerHTML in four places; owner: studio, until 2027-01-31");
495 /// # let _ = studio;
496 /// ```
497 ///
498 /// Verified by: crates/forma-server/tests/trusted_types_report_only.rs > "a_relaxed_route_keeps_reporting_and_is_logged_once_per_route_and_reason"
499 #[must_use]
500 pub fn relax_trusted_types(mut self, reason: impl Into<String>) -> Self {
501 self.relax_trusted_types = Some(reason.into());
502 self
503 }
504}
505
506/// An extension [`PolicyExtensions`] refused: the page fails instead of
507/// going out with a policy nobody meant.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct PolicyError {
510 directive: &'static str,
511 source: String,
512 reason: &'static str,
513}
514
515impl PolicyError {
516 /// The directive the source was added to, such as `img-src`, or
517 /// `content-security-policy` for a whole policy edited as text.
518 pub fn directive(&self) -> &'static str {
519 self.directive
520 }
521
522 /// The source expression as the handler wrote it (the whole policy text,
523 /// for one edited as text).
524 pub fn source_expression(&self) -> &str {
525 &self.source
526 }
527
528 /// Why it was refused.
529 pub fn reason(&self) -> &'static str {
530 self.reason
531 }
532}
533
534impl fmt::Display for PolicyError {
535 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
536 write!(
537 f,
538 "invalid {} extension {:?}: {}",
539 self.directive, self.source, self.reason
540 )
541 }
542}
543
544impl std::error::Error for PolicyError {}
545
546/// A page's policy after its extensions: the two header values a page
547/// response writes from it.
548#[derive(Debug, Clone)]
549///
550/// Its fields are private to this module, so a policy is made only here, by
551/// [`page_policy`] (the strict policy, extended) or [`frame_policy`] (the
552/// preview frame's): no other code can write one, which is what makes
553/// `PageResponse::frame` the only response whose policy is rewritten.
554pub(crate) struct PagePolicy {
555 /// `Content-Security-Policy`.
556 csp: axum::http::HeaderValue,
557 /// `Content-Security-Policy-Report-Only`: Trusted Types, when
558 /// an endpoint is named or the build is a development one.
559 report_only: Option<axum::http::HeaderValue>,
560 /// `Reporting-Endpoints`, when the extensions name an endpoint.
561 reporting_endpoints: Option<axum::http::HeaderValue>,
562 /// Why Trusted Types are relaxed on this route, when they are.
563 relaxed: Option<String>,
564 /// `Cross-Origin-Opener-Policy`.
565 opener: axum::http::HeaderValue,
566}
567
568impl PagePolicy {
569 /// `Content-Security-Policy`.
570 pub(crate) fn csp(&self) -> &axum::http::HeaderValue {
571 &self.csp
572 }
573
574 /// `Content-Security-Policy-Report-Only`, when the page sends one.
575 pub(crate) fn report_only(&self) -> Option<&axum::http::HeaderValue> {
576 self.report_only.as_ref()
577 }
578
579 /// `Reporting-Endpoints`, when an endpoint is named.
580 pub(crate) fn reporting_endpoints(&self) -> Option<&axum::http::HeaderValue> {
581 self.reporting_endpoints.as_ref()
582 }
583
584 /// Why Trusted Types are relaxed, when they are.
585 pub(crate) fn relaxed(&self) -> Option<&str> {
586 self.relaxed.as_deref()
587 }
588
589 /// `Cross-Origin-Opener-Policy`.
590 pub(crate) fn opener(&self) -> &axum::http::HeaderValue {
591 &self.opener
592 }
593}
594
595/// The strict policy for `nonce` with `extensions` applied, or the first
596/// refused extension.
597pub(crate) fn page_policy(
598 nonce: &CspNonce,
599 extensions: &PolicyExtensions,
600) -> Result<PagePolicy, PolicyError> {
601 let added: [(&'static str, &[String]); 3] = [
602 ("img-src", &extensions.img_src),
603 ("frame-src", &extensions.frame_src),
604 ("form-action", &extensions.form_action),
605 ];
606 for (directive, sources) in added {
607 for source in sources {
608 check_source(directive, source)?;
609 }
610 }
611 if let Some(reporting) = &extensions.reporting {
612 check_endpoint(reporting.method(), reporting.endpoint())?;
613 }
614 if let Some(reason) = &extensions.relax_trusted_types {
615 if reason.trim().is_empty() {
616 return Err(PolicyError {
617 directive: "trusted-types",
618 source: reason.clone(),
619 reason: "a relaxation names why, and on the allowlist its owner and end date",
620 });
621 }
622 }
623
624 let mut directives: Vec<(&'static str, Vec<String>)> =
625 strict_directives(nonce.as_str()).into_iter().collect();
626 if extensions.relax_trusted_types.is_some() {
627 directives.retain(|(name, _)| !TRUSTED_TYPES.iter().any(|(tt, _)| tt == name));
628 }
629 for (directive, sources) in added {
630 if sources.is_empty() {
631 continue;
632 }
633 let index = match directives.iter().position(|(n, _)| *n == directive) {
634 Some(index) => index,
635 None => {
636 directives.push((directive, Vec::new()));
637 directives.len() - 1
638 }
639 };
640 if let Some((_, list)) = directives.get_mut(index) {
641 for source in sources {
642 if !list.contains(source) {
643 list.push(source.clone());
644 }
645 }
646 }
647 }
648
649 let reporting = extensions.reporting.as_ref();
650 if let Some(reporting) = reporting {
651 directives.extend(reporting.directives());
652 }
653
654 // Unreachable for sources and an endpoint the checks accepted, which are
655 // visible ASCII; kept as an error so that no path drops a header.
656 let header = |directive: &'static str, text: String| {
657 axum::http::HeaderValue::from_str(&text).map_err(|_| PolicyError {
658 directive,
659 source: text,
660 reason: "not a valid header value",
661 })
662 };
663 let csp = header(
664 "content-security-policy",
665 write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice()))),
666 )?;
667 let report_only = match reporting {
668 Some(reporting) => Some(report_only_text(Some(reporting))),
669 None if extensions.relax_trusted_types.is_some() => Some(report_only_text(None)),
670 None => report_only_policy(),
671 }
672 .map(|text| header("content-security-policy-report-only", text))
673 .transpose()?;
674 let reporting_endpoints = match reporting {
675 Some(Reporting::Both(endpoint)) => Some(header(
676 "report-to",
677 format!("{REPORTING_GROUP}=\"{endpoint}\""),
678 )?),
679 Some(Reporting::UriOnly(_)) | None => None,
680 };
681 let opener = axum::http::HeaderValue::from_static(if extensions.allow_popups {
682 "same-origin-allow-popups"
683 } else {
684 "same-origin"
685 });
686 Ok(PagePolicy {
687 csp,
688 report_only,
689 reporting_endpoints,
690 relaxed: extensions.relax_trusted_types.clone(),
691 opener,
692 })
693}
694
695/// How many distinct (route, reason) relaxations are logged before the log
696/// stops naming new ones: a bound on the set the log keeps.
697const RELAXATIONS_LOGGED: usize = 1024;
698
699/// Log a render of a route whose Trusted Types are relaxed: once per route
700/// and reason per process, so a busy route does not flood the log. The route
701/// is the declared route pattern, never a request-specific path.
702///
703/// After a pair's first render this takes the lock, finds the pair and
704/// allocates nothing. The set is bounded: past [`RELAXATIONS_LOGGED`] pairs
705/// (an application passing request paths, not route patterns, as
706/// `route_pattern` would get there) one warning says so and later new pairs
707/// are not logged.
708///
709/// Verified by: crates/forma-server/tests/trusted_types_report_only.rs > "a_relaxed_route_keeps_reporting_and_is_logged_once_per_route_and_reason"
710pub(crate) fn log_relaxation(route: &str, reason: &str) {
711 use std::collections::{BTreeMap, BTreeSet};
712 use std::sync::Mutex;
713 struct Logged {
714 pairs: BTreeMap<String, BTreeSet<String>>,
715 count: usize,
716 }
717 static LOGGED: Mutex<Logged> = Mutex::new(Logged {
718 pairs: BTreeMap::new(),
719 count: 0,
720 });
721 // Nothing panics while holding the lock; a poisoned set is still a set.
722 let mut logged = LOGGED
723 .lock()
724 .unwrap_or_else(|poisoned| poisoned.into_inner());
725 if logged
726 .pairs
727 .get(route)
728 .is_some_and(|reasons| reasons.contains(reason))
729 {
730 return;
731 }
732 if logged.count > RELAXATIONS_LOGGED {
733 return;
734 }
735 logged.count += 1;
736 if logged.count > RELAXATIONS_LOGGED {
737 tracing::warn!(
738 "Trusted Types relaxed on more than {RELAXATIONS_LOGGED} (route, reason) pairs; \
739 further ones are not logged. route_pattern is a route's pattern (/users/:id), \
740 not a request's path."
741 );
742 return;
743 }
744 logged
745 .pairs
746 .entry(route.to_string())
747 .or_default()
748 .insert(reason.to_string());
749 drop(logged);
750 tracing::warn!(
751 route,
752 reason,
753 "Trusted Types relaxed on route {route}: {reason}. An allowlisted exit: this route \
754 keeps Trusted Types in Report-Only while other routes enforce. Logged once per \
755 route and reason per process."
756 );
757}
758
759/// Accept a reporting endpoint, or say why not: a path on the page's own
760/// origin, an `https://` URL, or `http://` on a loopback host for
761/// development; written as visible ASCII that cannot end a directive (`;`),
762/// split a header list (`,`), close the `Reporting-Endpoints` string (`"`,
763/// `\`), or add a source (whitespace, `'`).
764fn check_endpoint(directive: &'static str, endpoint: &str) -> Result<(), PolicyError> {
765 let refuse = |reason| {
766 Err(PolicyError {
767 directive,
768 source: endpoint.to_string(),
769 reason,
770 })
771 };
772 if endpoint.is_empty() {
773 return refuse("empty endpoint");
774 }
775 if endpoint.len() > 2048 {
776 return refuse("longer than 2048 bytes");
777 }
778 // RFC 3986 characters, less `;`, `,` and `'` (and so no quote,
779 // backslash, whitespace, control or non-ASCII byte), and no fragment.
780 let chars_ok = endpoint.bytes().all(|b| {
781 b.is_ascii_alphanumeric()
782 || matches!(
783 b,
784 b'-' | b'.'
785 | b'_'
786 | b'~'
787 | b':'
788 | b'/'
789 | b'?'
790 | b'['
791 | b']'
792 | b'@'
793 | b'!'
794 | b'$'
795 | b'&'
796 | b'('
797 | b')'
798 | b'*'
799 | b'+'
800 | b'='
801 | b'%'
802 )
803 });
804 if !chars_ok {
805 return refuse(
806 "an endpoint is visible ASCII URL characters, without ';', ',', quotes, a fragment \
807 or whitespace",
808 );
809 }
810 if endpoint.starts_with("//") {
811 return refuse("a scheme-relative endpoint; write https:// or a path");
812 }
813 if endpoint.starts_with('/') {
814 return Ok(());
815 }
816 let lower = endpoint.to_ascii_lowercase();
817 let (secure, rest) = if let Some(rest) = lower.strip_prefix("https://") {
818 (true, rest)
819 } else if let Some(rest) = lower.strip_prefix("http://") {
820 (false, rest)
821 } else {
822 return refuse(
823 "an endpoint is a path on the page's origin (/csp-reports) or an https:// URL",
824 );
825 };
826 let Some(host) = endpoint_host(rest) else {
827 return refuse(
828 "an endpoint's authority is a host (letters, digits, '.' and '-', or a bracketed \
829 IPv6 address) and an optional port of digits: no userinfo ('@'), nothing else",
830 );
831 };
832 if secure {
833 return Ok(());
834 }
835 if !matches!(host, "localhost" | "127.0.0.1" | "[::1]") {
836 return refuse(
837 "http:// only on localhost, 127.0.0.1 or [::1]: browsers send reports only to a \
838 secure endpoint",
839 );
840 }
841 // A loopback endpoint on a deployed page sends every visitor's reports
842 // to the visitor's own machine: allowed in development builds only.
843 // Verified by: crates/forma-server/src/csp.rs > "endpoints_accept_a_path_https_or_loopback_http_and_refuse_the_rest"
844 if cfg!(debug_assertions) {
845 Ok(())
846 } else {
847 refuse(
848 "a loopback http:// endpoint in a release build would send each visitor's reports to \
849 their own machine; name a path on the page's origin (/csp-reports) or an https:// URL",
850 )
851 }
852}
853
854/// The host of an absolute endpoint's authority (the text after `scheme://`
855/// up to the first `/` or `?`), or `None` when the authority is not a host
856/// and an optional port: a reg-name of `[a-z0-9.-]` with no empty label, or
857/// a bracketed IPv6 literal with nothing after `]` but a port; a port of one
858/// to five digits; never userinfo. `rest` is lower-cased.
859fn endpoint_host(rest: &str) -> Option<&str> {
860 let authority = rest.split(['/', '?']).next().unwrap_or("");
861 if authority.contains('@') {
862 return None;
863 }
864 let (host, port) = if authority.starts_with('[') {
865 let close = authority.find(']')?;
866 let (host, after) = authority.split_at(close + 1);
867 let literal = &host[1..host.len() - 1];
868 if literal.parse::<std::net::Ipv6Addr>().is_err() {
869 return None;
870 }
871 match after {
872 "" => (host, None),
873 _ => (host, Some(after.strip_prefix(':')?)),
874 }
875 } else {
876 match authority.split_once(':') {
877 Some((host, port)) => (host, Some(port)),
878 None => (authority, None),
879 }
880 };
881 let host_ok = host.starts_with('[') || is_reg_name(host);
882 let port_ok = port.is_none_or(is_port);
883 (host_ok && port_ok).then_some(host)
884}
885
886/// A host name as an endpoint or origin may write it, already lower-cased:
887/// dot-separated labels of `[a-z0-9-]`, none empty and none starting or
888/// ending with `-`.
889fn is_reg_name(host: &str) -> bool {
890 !host.is_empty()
891 && host.split('.').all(|label| {
892 !label.is_empty()
893 && !label.starts_with('-')
894 && !label.ends_with('-')
895 && label
896 .bytes()
897 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
898 })
899}
900
901/// A port as an endpoint or origin may write it: digits only (`u16`'s
902/// parser would take a `+`), no leading zero (which also refuses 0), and a
903/// `u16`.
904fn is_port(port: &str) -> bool {
905 !port.is_empty()
906 && port.bytes().all(|b| b.is_ascii_digit())
907 && !port.starts_with('0')
908 && port.parse::<u16>().is_ok()
909}
910
911// ---------------------------------------------------------------------------
912// The preview frame's policy (DR-14 tier (c), L-17, L-27)
913// ---------------------------------------------------------------------------
914
915/// The preview frame document's policy: the strict policy with exactly
916/// nine directives written in their place, the one exception to the rule
917/// that a page's policy is never rewritten:
918///
919/// - `sandbox allow-scripts`, without `allow-same-origin`: the document gets
920/// an opaque origin, so it reaches none of the application's cookies,
921/// storage or same-origin windows; scripts may run, and nothing else the
922/// sandbox gates (forms, popups, top navigation, downloads);
923/// - `frame-ancestors <app_origin>`: only the application's own pages may
924/// frame it;
925/// - `script-src 'sha256-<runtime>'`: the inline runtime runs, by its hash,
926/// and no other script, not one it creates or imports, which a nonce would
927/// have let through (a dynamic `import()` inherits its importer's nonce);
928/// - `connect-src 'none'`: no `fetch`, XHR, WebSocket, EventSource or beacon;
929/// - `img-src data:`, `style-src 'nonce-N'` alone, `font-src 'none'`,
930/// `worker-src 'none'` and `form-action 'none'`: in an opaque-origin
931/// document CSP's `'self'` still names the application's origin, so the
932/// strict policy's `'self'` sources would let markup alone (an `<img>`, a
933/// stylesheet, a `url()` in a `style` attribute, a font) send requests to
934/// the application, with its `SameSite=None` cookies.
935///
936/// The rest are the strict policy's, unchanged: `default-src`, `object-src`
937/// and `base-uri 'none'`, and `style-src-attr 'unsafe-inline'` (inline
938/// `style` attributes, whose `url()`s `img-src` and `font-src` now confine).
939/// Trusted Types are reported with no reporting endpoint (an unreviewed
940/// document's violations are not the application's reports), so, as on any
941/// page without one, in development builds only ([`report_only_policy`]).
942/// The header must be the enforced one: `sandbox` is ignored in Report-Only
943/// and in `meta`. `app_origin` is checked by [`check_origin`] first, and
944/// `runtime_sha256` is base64; `None` only if a header value could not be
945/// made, which those rule out.
946///
947/// Verified by: crates/forma-server/tests/internal_renderer/preview_frame.rs > "the_frame_policy_replaces_exactly_its_nine_directives"
948pub(crate) fn frame_policy(
949 nonce: &CspNonce,
950 runtime_sha256: &str,
951 app_origin: &str,
952) -> Option<PagePolicy> {
953 let mut directives: Vec<(&str, Vec<String>)> =
954 strict_directives(nonce.as_str()).into_iter().collect();
955 let none = || vec!["'none'".to_string()];
956 for (name, sources) in directives.iter_mut() {
957 match *name {
958 "script-src" => *sources = vec![format!("'sha256-{runtime_sha256}'")],
959 "style-src" => *sources = vec![format!("'nonce-{}'", nonce.as_str())],
960 "worker-src" | "connect-src" | "font-src" | "form-action" => *sources = none(),
961 "img-src" => *sources = vec!["data:".to_string()],
962 "frame-ancestors" => *sources = vec![app_origin.to_string()],
963 _ => {}
964 }
965 }
966 directives.push(("sandbox", vec!["allow-scripts".to_string()]));
967 let csp = write_policy(directives.iter().map(|(n, s)| (*n, s.as_slice())));
968 Some(PagePolicy {
969 csp: axum::http::HeaderValue::from_str(&csp).ok()?,
970 report_only: match report_only_policy() {
971 Some(text) => Some(axum::http::HeaderValue::from_str(&text).ok()?),
972 None => None,
973 },
974 reporting_endpoints: None,
975 relaxed: None,
976 opener: axum::http::HeaderValue::from_static("same-origin"),
977 })
978}
979
980/// Accept the application's own origin for the frame's `frame-ancestors`, or
981/// say why not: `https://host[:port]`, or `http://` on `localhost`,
982/// `127.0.0.1` or `[::1]` for development. An origin has no path, query,
983/// userinfo or wildcard, and its host is ASCII letters, digits, `-` and `.`
984/// (an IPv6 host only as `[::1]`). Returned in lower case.
985pub(crate) fn check_origin(origin: &str) -> Result<String, &'static str> {
986 let lower = origin.to_ascii_lowercase();
987 let (secure, authority) = if let Some(rest) = lower.strip_prefix("https://") {
988 (true, rest)
989 } else if let Some(rest) = lower.strip_prefix("http://") {
990 (false, rest)
991 } else {
992 return Err("an origin is https://host[:port], or http:// on a loopback host");
993 };
994 let (host, port) = if authority.starts_with('[') {
995 match authority.split_once(']') {
996 Some((v6, rest)) => {
997 if !rest.is_empty() && !rest.starts_with(':') {
998 return Err("an origin is a scheme, a host and an optional port, nothing more");
999 }
1000 (format!("{v6}]"), rest.strip_prefix(':'))
1001 }
1002 None => return Err("an unclosed IPv6 host"),
1003 }
1004 } else {
1005 match authority.split_once(':') {
1006 Some((host, port)) => (host.to_string(), Some(port)),
1007 None => (authority.to_string(), None),
1008 }
1009 };
1010 let host_ok = host == "[::1]" || is_reg_name(&host);
1011 let port_ok = port.is_none_or(is_port);
1012 if !host_ok || !port_ok {
1013 return Err("an origin is a scheme, a host and an optional port, nothing more");
1014 }
1015 if !secure && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") {
1016 return Err(
1017 "http:// only on localhost, 127.0.0.1 or [::1]; serve the application over https",
1018 );
1019 }
1020 Ok(lower)
1021}
1022
1023/// Accept one CSP source expression a handler may add, or say why not.
1024///
1025/// CSP3's `source-expression` narrowed to what an extension may add:
1026/// `'self'`, a scheme source, or a host source. Every accepted byte is
1027/// visible ASCII other than `;`, `,` and `'` (bar `'self'` itself), so an
1028/// accepted source cannot end its directive, start another, or add a keyword.
1029fn check_source(directive: &'static str, source: &str) -> Result<(), PolicyError> {
1030 let refuse = |reason| {
1031 Err(PolicyError {
1032 directive,
1033 source: source.to_string(),
1034 reason,
1035 })
1036 };
1037 const WILDCARD: &str = "a bare * allows every host; name a scheme (https:) or a host";
1038 if source.is_empty() {
1039 return refuse("empty source");
1040 }
1041 if source.len() > 1024 {
1042 return refuse("longer than 1024 bytes");
1043 }
1044 if source == "'self'" {
1045 return Ok(());
1046 }
1047 if source.starts_with('\'') {
1048 return refuse(
1049 "only 'self' may be added; nonces, hashes, 'none', 'strict-dynamic' and \
1050 'unsafe-*' stay the policy's own",
1051 );
1052 }
1053 if source == "*" {
1054 return refuse(WILDCARD);
1055 }
1056
1057 // Scheme source: `scheme:` and nothing after.
1058 if let Some(scheme) = source.strip_suffix(':') {
1059 if is_scheme(scheme) {
1060 return match refused_scheme(directive, scheme) {
1061 Some(reason) => refuse(reason),
1062 None => Ok(()),
1063 };
1064 }
1065 }
1066
1067 // Host source: [scheme "://"] host [":" port] [path]. The scheme part is
1068 // held to the same rules as a scheme source: `javascript://x` is still a
1069 // script scheme.
1070 let rest = match source.split_once("://") {
1071 Some((scheme, rest)) if is_scheme(scheme) => {
1072 if let Some(reason) = refused_scheme(directive, scheme) {
1073 return refuse(reason);
1074 }
1075 rest
1076 }
1077 Some(_) => return refuse("not a CSP source expression"),
1078 None => source,
1079 };
1080 let (authority, path) = match rest.find('/') {
1081 Some(at) => rest.split_at(at),
1082 None => (rest, ""),
1083 };
1084 let (host, port) = match authority.rsplit_once(':') {
1085 Some((host, port)) => (host, Some(port)),
1086 None => (authority, None),
1087 };
1088 if host == "*" {
1089 return refuse(WILDCARD);
1090 }
1091 let labels = host.strip_prefix("*.").unwrap_or(host);
1092 let host_ok = !labels.is_empty()
1093 && labels.split('.').all(|label| {
1094 !label.is_empty()
1095 && label
1096 .bytes()
1097 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
1098 });
1099 let port_ok = match port {
1100 None => true,
1101 Some(p) => p == "*" || (!p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())),
1102 };
1103 // RFC 3986 path characters, less `;`, `,` and `'`.
1104 let path_ok = path.bytes().all(|b| {
1105 b.is_ascii_alphanumeric()
1106 || matches!(
1107 b,
1108 b'/' | b'-'
1109 | b'.'
1110 | b'_'
1111 | b'~'
1112 | b'%'
1113 | b'!'
1114 | b'$'
1115 | b'&'
1116 | b'('
1117 | b')'
1118 | b'*'
1119 | b'+'
1120 | b'='
1121 | b':'
1122 | b'@'
1123 )
1124 });
1125 if host_ok && port_ok && path_ok {
1126 Ok(())
1127 } else {
1128 refuse("not a CSP source expression")
1129 }
1130}
1131
1132/// Why `scheme` (in either source form) may not be added to `directive`, if
1133/// it may not. Schemes compare without case, as browsers do.
1134///
1135/// - `javascript:` and `vbscript:` are never a source.
1136/// - `data:` and `filesystem:` are not a frame or form target: no consumer
1137/// frames or posts to them (GateWASM adds `'self'`, Generative UI `blob:`),
1138/// and each carries content the page chose instead of naming a place.
1139/// Images may still use `data:`, as the strict policy's own `img-src` does.
1140fn refused_scheme(directive: &str, scheme: &str) -> Option<&'static str> {
1141 let scheme = scheme.to_ascii_lowercase();
1142 if scheme == "javascript" || scheme == "vbscript" {
1143 return Some("a script scheme is never a source");
1144 }
1145 if matches!(directive, "frame-src" | "form-action")
1146 && (scheme == "data" || scheme == "filesystem")
1147 {
1148 return Some("data: and filesystem: are not a frame or form target");
1149 }
1150 None
1151}
1152
1153/// CSP's `scheme-part`: a letter, then letters, digits, `+`, `-` or `.`.
1154fn is_scheme(s: &str) -> bool {
1155 let mut bytes = s.bytes();
1156 matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic())
1157 && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 /// Extract one directive's value from a policy string.
1165 fn directive<'a>(csp: &'a str, name: &str) -> Option<&'a str> {
1166 csp.split(';')
1167 .map(str::trim)
1168 .find_map(|d| d.strip_prefix(name).filter(|rest| rest.starts_with(' ')))
1169 .map(str::trim)
1170 }
1171
1172 #[test]
1173 fn nonce_is_unique() {
1174 let a = generate_csp_nonce().unwrap();
1175 let b = generate_csp_nonce().unwrap();
1176 assert_ne!(a, b);
1177 }
1178
1179 #[test]
1180 fn try_generate_draws_distinct_44_character_nonces() {
1181 let a = CspNonce::try_generate().unwrap();
1182 let b = CspNonce::try_generate().unwrap();
1183 assert_ne!(a, b);
1184 assert_eq!(a.as_str().len(), 44);
1185 assert_eq!(CspNonce::parse(a.as_str()), Ok(a));
1186 }
1187
1188 #[test]
1189 fn nonce_is_base64() {
1190 let nonce = generate_csp_nonce().unwrap();
1191 assert_eq!(nonce.as_str().len(), 44); // 32 bytes -> 44 base64 chars
1192 assert!(STANDARD.decode(nonce.as_str()).is_ok());
1193 }
1194
1195 #[test]
1196 fn csp_contains_nonce() {
1197 let nonce = generate_csp_nonce().unwrap();
1198 let csp = build_csp_header(&nonce);
1199 assert!(csp.contains(&format!("'nonce-{nonce}'")));
1200 }
1201
1202 #[test]
1203 fn parse_rejects_attribute_and_directive_breakout() {
1204 // These are exactly the payloads the newtype exists to stop: the first
1205 // breaks out of nonce="..." on every script tag, the second rewrites
1206 // the policy into an unsafe-inline one from outside.
1207 for bad in [
1208 "\" onload=\"alert(1)",
1209 "x' 'unsafe-inline",
1210 "short",
1211 "has spaces in it here",
1212 "semi;colon;value;padding",
1213 ] {
1214 assert_eq!(CspNonce::parse(bad), Err(InvalidNonce), "accepted {bad:?}");
1215 }
1216 }
1217
1218 #[test]
1219 fn parse_accepts_a_generated_nonce() {
1220 let generated = CspNonce::generate().unwrap();
1221 assert_eq!(CspNonce::parse(generated.as_str()), Ok(generated));
1222 }
1223
1224 #[test]
1225 fn failed_draw_returns_no_nonce_even_after_a_partial_fill() {
1226 let result = CspNonce::generate_with(|bytes| {
1227 bytes[..8].fill(0x61);
1228 Err(NonceError)
1229 });
1230 assert_eq!(result, Err(NonceError));
1231 }
1232
1233 #[test]
1234 fn successful_draw_encodes_all_32_bytes_once() {
1235 let mut calls = 0;
1236 let nonce = CspNonce::generate_with(|bytes| {
1237 calls += 1;
1238 bytes.fill(0x61);
1239 Ok(())
1240 })
1241 .unwrap();
1242 assert_eq!(calls, 1);
1243 assert_eq!(STANDARD.decode(nonce.as_str()).unwrap(), vec![0x61; 32]);
1244 assert_eq!(CspNonce::parse(nonce.as_str()), Ok(nonce));
1245 }
1246
1247 #[test]
1248 fn strict_base_preserves_existing_directives_and_adds_enforced_trusted_types() {
1249 // The existing strict directives retain their order and values;
1250 // both Trusted Types directives are now part of enforcement.
1251 let nonce = CspNonce::parse("test-nonce-value-0123456789").unwrap();
1252 assert_eq!(
1253 build_csp_header(&nonce),
1254 "default-src 'none'; \
1255 script-src 'nonce-test-nonce-value-0123456789' 'strict-dynamic' 'self'; \
1256 style-src 'nonce-test-nonce-value-0123456789' 'self'; \
1257 style-src-attr 'unsafe-inline'; \
1258 worker-src 'self'; \
1259 object-src 'none'; \
1260 connect-src 'self'; \
1261 img-src 'self' data:; \
1262 font-src 'self'; \
1263 frame-ancestors 'none'; \
1264 base-uri 'none'; \
1265 form-action 'self'; require-trusted-types-for 'script'; trusted-types forma forma-runtime"
1266 );
1267 }
1268
1269 #[test]
1270 fn extension_sources_accept_what_consumers_add() {
1271 // The negative space of the refusals below: every shape GateWASM,
1272 // Generative UI and a login page need must pass.
1273 for source in [
1274 "'self'",
1275 "https:",
1276 "http:",
1277 "blob:",
1278 "data:",
1279 "wss:",
1280 "cdn.example.com",
1281 "https://cdn.example.com",
1282 "https://*.example.com",
1283 "https://cdn.example.com:8443",
1284 "https://cdn.example.com:*",
1285 "https://cdn.example.com/images/",
1286 "https://cdn.example.com/a-b_c~d%20e/f.png",
1287 "localhost:3000",
1288 "127.0.0.1:8080",
1289 ] {
1290 assert_eq!(check_source("img-src", source), Ok(()), "{source:?}");
1291 }
1292 }
1293
1294 #[test]
1295 fn extension_sources_refuse_injection_and_loosening() {
1296 for (source, why) in [
1297 ("", "empty"),
1298 ("*", "every host"),
1299 ("https://*", "every host"),
1300 ("'none'", "only 'self'"),
1301 ("'unsafe-inline'", "only 'self'"),
1302 ("'unsafe-eval'", "only 'self'"),
1303 ("'unsafe-hashes'", "only 'self'"),
1304 ("'strict-dynamic'", "only 'self'"),
1305 ("'nonce-abcdefghijklmnopqrstuv'", "only 'self'"),
1306 ("'sha256-abc='", "only 'self'"),
1307 ("'SELF'", "only 'self'"),
1308 ("javascript:", "script scheme"),
1309 ("JavaScript:", "script scheme"),
1310 ("vbscript:", "script scheme"),
1311 ("javascript://x", "script scheme"),
1312 ("JAVASCRIPT://x/%0aalert(1)", "script scheme"),
1313 ("vbscript://x/%0aalert(1)", "script scheme"),
1314 ("VBScript://x", "script scheme"),
1315 ("https://a.example; script-src *", "source expression"),
1316 ("https://a.example;", "source expression"),
1317 ("https://a.example,b.example", "source expression"),
1318 ("https://a.example b.example", "source expression"),
1319 ("https://a.example\tb", "source expression"),
1320 ("https://a.example\r\nx: y", "source expression"),
1321 ("https://a.example/'x'", "source expression"),
1322 ("https://a.example/\"x\"", "source expression"),
1323 ("https://a..example", "source expression"),
1324 ("https://a.example:80x", "source expression"),
1325 ("https://a.example:", "source expression"),
1326 ("1https://a.example", "source expression"),
1327 ("ht tp://a.example", "source expression"),
1328 ("https://", "source expression"),
1329 ("https://[::1]", "source expression"),
1330 ("a.example/ü", "source expression"),
1331 ] {
1332 let error = check_source("frame-src", source).expect_err(source);
1333 assert_eq!(error.directive(), "frame-src");
1334 assert_eq!(error.source_expression(), source);
1335 assert!(
1336 error.reason().contains(why),
1337 "{source:?}: {} should say {why:?}",
1338 error.reason()
1339 );
1340 }
1341 // Over-long input is refused before it is parsed.
1342 let long = format!("https://{}.example", "a".repeat(1100));
1343 assert!(check_source("img-src", &long).is_err());
1344 }
1345
1346 #[test]
1347 fn frame_and_form_targets_refuse_data_and_filesystem() {
1348 // No consumer frames or posts to them (GateWASM: `'self'`; Generative
1349 // UI: `blob:`), and both carry content the page chose rather than a
1350 // place it loads from. Images may still use `data:`, as the strict
1351 // policy's own `img-src` does.
1352 for directive in ["frame-src", "form-action"] {
1353 for source in [
1354 "data:",
1355 "DATA:",
1356 "data://x",
1357 "filesystem:",
1358 "FileSystem://x/y",
1359 ] {
1360 let error = check_source(directive, source).expect_err(source);
1361 assert!(
1362 error.reason().contains("frame or form"),
1363 "{directive} {source:?}: {}",
1364 error.reason()
1365 );
1366 }
1367 }
1368 for (directive, source) in [
1369 ("img-src", "data:"),
1370 ("img-src", "blob:"),
1371 ("frame-src", "blob:"),
1372 ("frame-src", "'self'"),
1373 ("frame-src", "https:"),
1374 ("form-action", "https://idp.example.com"),
1375 ] {
1376 assert_eq!(
1377 check_source(directive, source),
1378 Ok(()),
1379 "{directive} {source}"
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn csp_is_strict() {
1386 let csp = build_csp_header(&CspNonce::parse("test-nonce-value-0123456789").unwrap());
1387
1388 assert_eq!(directive(&csp, "default-src"), Some("'none'"));
1389
1390 // Scripts: nonce + strict-dynamic, never unsafe-*.
1391 let script = directive(&csp, "script-src").expect("script-src");
1392 assert!(script.contains("'nonce-"));
1393 assert!(
1394 script.contains("'strict-dynamic'"),
1395 "without strict-dynamic the 'self' host-source is still honoured"
1396 );
1397 assert!(!script.contains("unsafe-inline"));
1398 assert!(!script.contains("unsafe-eval"));
1399 assert!(!csp.contains("wasm-unsafe-eval"));
1400
1401 // Style ELEMENTS stay nonce-locked...
1402 let style = directive(&csp, "style-src").expect("style-src");
1403 assert!(style.contains("'nonce-"));
1404 assert!(!style.contains("unsafe-inline"));
1405
1406 // ...while style ATTRIBUTES are permitted, because compiled `style:`
1407 // bindings emit them.
1408 assert_eq!(directive(&csp, "style-src-attr"), Some("'unsafe-inline'"));
1409 // No style-src-elem override: the fallback to nonce-locked style-src
1410 // is what keeps <style> blocks strict.
1411 assert_eq!(directive(&csp, "style-src-elem"), None);
1412
1413 // strict-dynamic does not cover workers, so this must be explicit.
1414 assert_eq!(directive(&csp, "worker-src"), Some("'self'"));
1415 assert_eq!(directive(&csp, "object-src"), Some("'none'"));
1416
1417 // SR39: that attribute allowance is the policy's only
1418 // 'unsafe-inline', in the strict policy and in one extended every way
1419 // a handler can, reporting endpoint included.
1420 let nonce = CspNonce::parse("test-nonce-value-0123456789").unwrap();
1421 let extended = page_policy(
1422 &nonce,
1423 &PolicyExtensions::new()
1424 .img_src("https:")
1425 .frame_src("'self'")
1426 .form_action("https://idp.example.com")
1427 .allow_popups()
1428 .report_to("/csp-reports")
1429 .relax_trusted_types("a page not moved yet"),
1430 )
1431 .unwrap();
1432 let extended_csp = extended.csp.to_str().unwrap();
1433 for policy in [csp.as_str(), extended_csp] {
1434 assert_eq!(policy.matches("'unsafe-inline'").count(), 1, "{policy}");
1435 assert_eq!(directive(policy, "style-src-attr"), Some("'unsafe-inline'"));
1436 assert!(!policy.contains("unsafe-eval") && !policy.contains("unsafe-hashes"));
1437 }
1438
1439 // Trusted Types enforcement and the optional reporting copy: both directives,
1440 // with Forma's two policies and no 'allow-duplicates' (TH18), and no
1441 // 'unsafe-*' at all. Only the named relaxed policy omits them.
1442 let report_only = report_only_text(None);
1443 assert_eq!(
1444 report_only,
1445 "require-trusted-types-for 'script'; trusted-types forma forma-runtime"
1446 );
1447 assert_eq!(
1448 directive(&report_only, "require-trusted-types-for"),
1449 Some("'script'")
1450 );
1451 assert_eq!(
1452 directive(&report_only, "trusted-types"),
1453 Some("forma forma-runtime")
1454 );
1455 assert!(!report_only.contains("unsafe") && !report_only.contains("allow-duplicates"));
1456 // Named an endpoint, every build sends it.
1457 let extended_report_only = extended.report_only.as_ref().unwrap().to_str().unwrap();
1458 assert!(
1459 extended_report_only.starts_with(&report_only),
1460 "{extended_report_only}"
1461 );
1462 assert!(!extended_report_only.contains("unsafe"));
1463 assert_eq!(
1464 directive(&csp, "require-trusted-types-for"),
1465 Some("'script'")
1466 );
1467 assert_eq!(
1468 directive(&csp, "trusted-types"),
1469 Some("forma forma-runtime")
1470 );
1471 assert_eq!(directive(extended_csp, "require-trusted-types-for"), None);
1472 assert_eq!(directive(extended_csp, "trusted-types"), None);
1473 }
1474
1475 #[test]
1476 fn endpoints_accept_a_path_https_or_loopback_http_and_refuse_the_rest() {
1477 for endpoint in [
1478 "/csp-reports",
1479 "/r?app=studio&v=1",
1480 "/a-b_c.d~e/%20f",
1481 "https://reports.example.com/csp",
1482 "HTTPS://Reports.Example.com/x",
1483 "https://reports.example.com:8443/csp?x=1",
1484 "https://reports.example.com?app=studio",
1485 "https://127.0.0.1:8443/csp",
1486 "https://[2001:db8::1]:8443/csp",
1487 "https://[::ffff:192.0.2.1]/csp",
1488 "https://reports.example:65535/csp",
1489 ] {
1490 assert_eq!(
1491 check_endpoint("report-to", endpoint),
1492 Ok(()),
1493 "{endpoint:?}"
1494 );
1495 }
1496 // Loopback http:// in development builds only: deployed, it would
1497 // send each visitor's reports to the visitor's own machine (N5).
1498 for endpoint in [
1499 "http://localhost:3000/csp",
1500 "http://127.0.0.1/csp",
1501 "http://[::1]:8080/csp",
1502 "HTTP://LOCALHOST/csp",
1503 ] {
1504 let checked = check_endpoint("report-uri", endpoint);
1505 if cfg!(debug_assertions) {
1506 assert_eq!(checked, Ok(()), "{endpoint:?}");
1507 } else {
1508 let error = checked.expect_err(endpoint);
1509 assert!(error.reason().contains("release build"), "{error}");
1510 }
1511 }
1512 for endpoint in [
1513 "",
1514 "csp-reports",
1515 "//evil.example/x",
1516 "http://evil.example/x",
1517 "http://localhost.evil.example/x",
1518 "http://localhost@evil.example/x",
1519 "http://127.0.0.1.evil.example/x",
1520 "https://",
1521 "https:///x",
1522 "javascript:alert(1)",
1523 "JavaScript:alert(1)",
1524 "data:,x",
1525 "ftp://reports.example.com/x",
1526 "/a;script-src *",
1527 "/a; script-src *",
1528 "/a,b",
1529 "/a b",
1530 "/a\tb",
1531 "/a\nb",
1532 "/a\"b",
1533 "/a'b",
1534 "/a\\b",
1535 "/a#frag",
1536 "/ü",
1537 "/a<b>",
1538 // Userinfo would publish a credential to every visitor, and
1539 // authorities a URL parser refuses would send reports nowhere
1540 // (review 5517672933, S1).
1541 "https://user:pw@reports.example/csp",
1542 "https://user@reports.example",
1543 "https://@reports.example/csp",
1544 "http://evil.com@localhost/csp",
1545 "http://[::1]evil.com",
1546 "http://[::1",
1547 "http://localhost:3000]",
1548 "https://reports.example:8443x",
1549 "https://reports.example:",
1550 "https://reports.example:123456",
1551 "https://%0d%0a",
1552 "https://]",
1553 "https://[]",
1554 "https://[zz::1]",
1555 "https://[::1]x:80",
1556 "https://reports..example/csp",
1557 "https://.example/csp",
1558 "https://reports_example/csp",
1559 "https://*.example/csp",
1560 "https://reports.example:8443:1/csp",
1561 // Ports as a u16 (no 0, none above 65535, no leading zero) and a
1562 // bracketed address that `Ipv6Addr` parses.
1563 "https://reports.example:0/csp",
1564 "https://reports.example:65536/csp",
1565 "https://reports.example:00443/csp",
1566 "https://[::1::2]/csp",
1567 "https://[12345::1]/csp",
1568 "https://[::1%25eth0]/csp",
1569 "https://-/csp",
1570 "https://-reports.example/csp",
1571 ] {
1572 let error = check_endpoint("report-to", endpoint).expect_err(endpoint);
1573 assert_eq!(error.directive(), "report-to", "{endpoint:?}");
1574 assert_eq!(error.source_expression(), endpoint);
1575 }
1576 let long = format!("/{}", "a".repeat(2048));
1577 assert!(check_endpoint("report-uri", &long).is_err());
1578 }
1579}