Skip to main content

forma_server/
csp.rs

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