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