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