Skip to main content

forma_server/
csp.rs

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