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