Skip to main content

csp_parse/
directive.rs

1//! The CSP3 directive registry (§6 and subsections): maps a known
2//! directive name to its value grammar, and interprets a [`Directive`]'s
3//! raw value accordingly -- turning "name + raw value" (Phase 02) plus
4//! the source-list parser (Phase 03) into a fully structured directive,
5//! as sketched by the architecture diagram in `CLAUDE.md`.
6//!
7//! **Registry completeness is a snapshot, not a permanent guarantee** —
8//! see `plan/04-directive-registry.md`'s risks section and the README's
9//! status/scope notes (Phase 06). `webrtc` is deliberately left
10//! unregistered pending verification against the then-current spec text
11//! (its standardization status was in flux); `referrer` (CSP1, removed
12//! before CSP3) is intentionally out of scope entirely -- this crate's
13//! normative basis is CSP3 only (`CLAUDE.md`).
14
15use crate::ast::Directive;
16use crate::source_list::{Keyword, SourceExpression, SourceList, parse_source_list};
17
18/// Which value grammar a directive's raw value should be parsed with.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ValueGrammar {
22    /// `serialized-source-list` (CSP3 §2.3.1).
23    SourceList,
24    /// `frame-ancestors`' restricted `ancestor-source-list`: parsed with
25    /// the same [`crate::parse_source_list`] as `SourceList`, but only
26    /// scheme-source, host-source, and `'self'` are valid there -- see
27    /// [`ancestor_source_list_is_valid`].
28    AncestorSourceList,
29    /// `sandbox`'s whitespace-separated, unquoted token list.
30    SandboxTokens,
31    /// No value expected (e.g. `upgrade-insecure-requests`).
32    Boolean,
33    /// A single raw token (e.g. `report-to`, `require-trusted-types-for`).
34    Token,
35    /// Whitespace-separated raw tokens with no further structure parsed
36    /// by this crate (e.g. `report-uri`'s URI references, `plugin-types`'
37    /// MIME-type patterns -- both grammars this crate deliberately does
38    /// not implement, matching the narrow, generic scope from
39    /// `CLAUDE.md`).
40    TokenList,
41    /// `trusted-types`' policy-name list (plus the `'allow-duplicates'`/
42    /// `'none'` sentinel tokens), kept as raw tokens for the same reason
43    /// as `TokenList`.
44    TrustedTypes,
45}
46
47/// Whether a registered directive is CSP3-current or deprecated.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum DirectiveStatus {
51    /// Part of the current CSP3 directive set.
52    Current,
53    /// Kept for backward compatibility, superseded by another directive
54    /// (e.g. `report-uri` by `report-to`) or removed from later spec
55    /// drafts, but still commonly seen in the wild.
56    Deprecated,
57}
58
59const REGISTRY: &[(&str, ValueGrammar, DirectiveStatus)] = &[
60    // Fetch directives (CSP3 §6.1).
61    (
62        "child-src",
63        ValueGrammar::SourceList,
64        DirectiveStatus::Current,
65    ),
66    (
67        "connect-src",
68        ValueGrammar::SourceList,
69        DirectiveStatus::Current,
70    ),
71    (
72        "default-src",
73        ValueGrammar::SourceList,
74        DirectiveStatus::Current,
75    ),
76    (
77        "font-src",
78        ValueGrammar::SourceList,
79        DirectiveStatus::Current,
80    ),
81    (
82        "frame-src",
83        ValueGrammar::SourceList,
84        DirectiveStatus::Current,
85    ),
86    (
87        "img-src",
88        ValueGrammar::SourceList,
89        DirectiveStatus::Current,
90    ),
91    (
92        "manifest-src",
93        ValueGrammar::SourceList,
94        DirectiveStatus::Current,
95    ),
96    (
97        "media-src",
98        ValueGrammar::SourceList,
99        DirectiveStatus::Current,
100    ),
101    (
102        "object-src",
103        ValueGrammar::SourceList,
104        DirectiveStatus::Current,
105    ),
106    (
107        "script-src",
108        ValueGrammar::SourceList,
109        DirectiveStatus::Current,
110    ),
111    (
112        "script-src-elem",
113        ValueGrammar::SourceList,
114        DirectiveStatus::Current,
115    ),
116    (
117        "script-src-attr",
118        ValueGrammar::SourceList,
119        DirectiveStatus::Current,
120    ),
121    (
122        "style-src",
123        ValueGrammar::SourceList,
124        DirectiveStatus::Current,
125    ),
126    (
127        "style-src-elem",
128        ValueGrammar::SourceList,
129        DirectiveStatus::Current,
130    ),
131    (
132        "style-src-attr",
133        ValueGrammar::SourceList,
134        DirectiveStatus::Current,
135    ),
136    (
137        "worker-src",
138        ValueGrammar::SourceList,
139        DirectiveStatus::Current,
140    ),
141    // Document directives (CSP3 §6.3).
142    (
143        "base-uri",
144        ValueGrammar::SourceList,
145        DirectiveStatus::Current,
146    ),
147    (
148        "sandbox",
149        ValueGrammar::SandboxTokens,
150        DirectiveStatus::Current,
151    ),
152    // Navigation directives (CSP3 §6.4).
153    (
154        "form-action",
155        ValueGrammar::SourceList,
156        DirectiveStatus::Current,
157    ),
158    (
159        "frame-ancestors",
160        ValueGrammar::AncestorSourceList,
161        DirectiveStatus::Current,
162    ),
163    // Reporting directives (CSP3 §6.5).
164    ("report-to", ValueGrammar::Token, DirectiveStatus::Current),
165    (
166        "report-uri",
167        ValueGrammar::TokenList,
168        DirectiveStatus::Deprecated,
169    ),
170    // Boolean/valueless directives.
171    (
172        "upgrade-insecure-requests",
173        ValueGrammar::Boolean,
174        DirectiveStatus::Current,
175    ),
176    (
177        "block-all-mixed-content",
178        ValueGrammar::Boolean,
179        DirectiveStatus::Deprecated,
180    ),
181    // Trusted Types directives.
182    (
183        "require-trusted-types-for",
184        ValueGrammar::Token,
185        DirectiveStatus::Current,
186    ),
187    (
188        "trusted-types",
189        ValueGrammar::TrustedTypes,
190        DirectiveStatus::Current,
191    ),
192    // Deprecated, CSP2-era.
193    (
194        "plugin-types",
195        ValueGrammar::TokenList,
196        DirectiveStatus::Deprecated,
197    ),
198];
199
200/// Looks up a directive name in the CSP3 directive registry.
201/// ASCII-case-insensitive, per CSP3's directive-name matching rule (see
202/// `plan/02-directive-splitting.md`). Returns `None` for unregistered
203/// names -- per CSP3's forward-compatibility design, that is not itself
204/// a syntax error (see `plan/DECISIONS.md`, 2026-08-22).
205pub fn registry_lookup(name: &str) -> Option<(ValueGrammar, DirectiveStatus)> {
206    REGISTRY
207        .iter()
208        .find(|(registered_name, _, _)| registered_name.eq_ignore_ascii_case(name))
209        .map(|(_, grammar, status)| (*grammar, *status))
210}
211
212/// A directive's value, interpreted according to its registered
213/// [`ValueGrammar`] (or [`DirectiveValue::Unknown`] if the directive
214/// name isn't in the registry).
215#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum DirectiveValue {
218    /// See [`ValueGrammar::SourceList`].
219    SourceList(SourceList),
220    /// See [`ValueGrammar::AncestorSourceList`].
221    AncestorSourceList(SourceList),
222    /// See [`ValueGrammar::SandboxTokens`].
223    Sandbox(Vec<String>),
224    /// See [`ValueGrammar::Boolean`].
225    Boolean,
226    /// See [`ValueGrammar::Token`].
227    Token(Option<String>),
228    /// See [`ValueGrammar::TokenList`].
229    TokenList(Vec<String>),
230    /// See [`ValueGrammar::TrustedTypes`].
231    TrustedTypes(Vec<String>),
232    /// The directive name isn't in the registry (see this module's docs
233    /// for what that does and doesn't imply).
234    Unknown,
235}
236
237impl Directive {
238    /// Interprets [`Directive::raw_value`] via the CSP3 directive
239    /// registry for [`Directive::name`]. Computed on demand rather than
240    /// stored on `Directive` itself, so [`crate::parse_policy_list`]
241    /// (Phase 02) stays a pure, registry-independent split -- see
242    /// `plan/DECISIONS.md`, 2026-08-22.
243    pub fn value(&self) -> DirectiveValue {
244        let Some((grammar, _status)) = registry_lookup(&self.name) else {
245            return DirectiveValue::Unknown;
246        };
247        let raw = self.raw_value.as_deref().unwrap_or("");
248        match grammar {
249            ValueGrammar::SourceList => DirectiveValue::SourceList(parse_source_list(raw)),
250            ValueGrammar::AncestorSourceList => {
251                DirectiveValue::AncestorSourceList(parse_source_list(raw))
252            }
253            ValueGrammar::SandboxTokens => DirectiveValue::Sandbox(tokenize(raw)),
254            ValueGrammar::Boolean => DirectiveValue::Boolean,
255            ValueGrammar::Token => {
256                DirectiveValue::Token(raw.split_ascii_whitespace().next().map(str::to_string))
257            }
258            ValueGrammar::TokenList => DirectiveValue::TokenList(tokenize(raw)),
259            ValueGrammar::TrustedTypes => DirectiveValue::TrustedTypes(tokenize(raw)),
260        }
261    }
262
263    /// For a [`ValueGrammar::Boolean`] directive (e.g.
264    /// `upgrade-insecure-requests`): whether it unexpectedly carries a
265    /// value. CSP3 defines these directives as valueless -- a present
266    /// value is a caller-visible anomaly (a diagnostic), not silently
267    /// dropped or a panic. `false` for non-boolean or unregistered
268    /// directives.
269    pub fn boolean_value_is_unexpected(&self) -> bool {
270        matches!(
271            registry_lookup(&self.name),
272            Some((ValueGrammar::Boolean, _))
273        ) && self.raw_value.is_some()
274    }
275}
276
277fn tokenize(raw: &str) -> Vec<String> {
278    raw.split_ascii_whitespace().map(str::to_string).collect()
279}
280
281/// Whether `list` only contains expressions valid in `frame-ancestors`'
282/// restricted `ancestor-source-list`: scheme-source, host-source, and
283/// `'self'` -- unlike a regular `source-list`, no `'unsafe-inline'` (or
284/// any other keyword), no nonce-source, no hash-source. Unrecognized
285/// entries (`expression: None`) also make the list invalid.
286pub fn ancestor_source_list_is_valid(list: &SourceList) -> bool {
287    match list {
288        SourceList::None => true,
289        SourceList::Sources(entries) => entries.iter().all(|entry| {
290            matches!(
291                entry.expression,
292                Some(SourceExpression::Scheme(_))
293                    | Some(SourceExpression::Host(_))
294                    | Some(SourceExpression::Keyword(Keyword::SelfKeyword))
295            )
296        }),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::parse_policy_list;
304
305    fn directive_value(policy_str: &str) -> DirectiveValue {
306        parse_policy_list(policy_str).policies[0].directives[0].value()
307    }
308
309    #[test]
310    fn fetch_directive_is_source_list() {
311        assert!(matches!(
312            directive_value("default-src 'self'"),
313            DirectiveValue::SourceList(_)
314        ));
315    }
316
317    #[test]
318    fn sandbox_tokens() {
319        assert_eq!(
320            directive_value("sandbox allow-scripts allow-forms"),
321            DirectiveValue::Sandbox(vec!["allow-scripts".to_string(), "allow-forms".to_string()])
322        );
323    }
324
325    #[test]
326    fn base_uri_and_form_action_are_source_list() {
327        assert!(matches!(
328            directive_value("base-uri 'self'"),
329            DirectiveValue::SourceList(_)
330        ));
331        assert!(matches!(
332            directive_value("form-action 'self'"),
333            DirectiveValue::SourceList(_)
334        ));
335    }
336
337    #[test]
338    fn frame_ancestors_accepts_self_and_hosts_but_rejects_unsafe_inline_nonce_hash() {
339        let allowed = directive_value("frame-ancestors 'self' example.com https:");
340        match allowed {
341            DirectiveValue::AncestorSourceList(list) => {
342                assert!(ancestor_source_list_is_valid(&list));
343            }
344            other => panic!("expected AncestorSourceList, got {other:?}"),
345        }
346
347        for rejected_raw in [
348            "frame-ancestors 'unsafe-inline'",
349            "frame-ancestors 'nonce-abc123'",
350            "frame-ancestors 'sha256-abc123'",
351        ] {
352            match directive_value(rejected_raw) {
353                DirectiveValue::AncestorSourceList(list) => {
354                    assert!(!ancestor_source_list_is_valid(&list), "{rejected_raw}");
355                }
356                other => panic!("expected AncestorSourceList, got {other:?}"),
357            }
358        }
359    }
360
361    #[test]
362    fn report_to_and_report_uri() {
363        assert_eq!(
364            directive_value("report-to endpoint-1"),
365            DirectiveValue::Token(Some("endpoint-1".to_string()))
366        );
367        assert_eq!(
368            directive_value("report-uri https://example.com/csp-report"),
369            DirectiveValue::TokenList(vec!["https://example.com/csp-report".to_string()])
370        );
371        assert_eq!(
372            registry_lookup("report-uri").map(|(_, status)| status),
373            Some(DirectiveStatus::Deprecated)
374        );
375    }
376
377    #[test]
378    fn boolean_directives() {
379        let list = parse_policy_list("upgrade-insecure-requests");
380        let directive = &list.policies[0].directives[0];
381        assert_eq!(directive.value(), DirectiveValue::Boolean);
382        assert!(!directive.boolean_value_is_unexpected());
383
384        let list = parse_policy_list("upgrade-insecure-requests 'self'");
385        let directive = &list.policies[0].directives[0];
386        assert_eq!(directive.value(), DirectiveValue::Boolean);
387        assert!(directive.boolean_value_is_unexpected());
388
389        assert_eq!(
390            registry_lookup("block-all-mixed-content").map(|(_, status)| status),
391            Some(DirectiveStatus::Deprecated)
392        );
393    }
394
395    #[test]
396    fn trusted_types_directives() {
397        assert_eq!(
398            directive_value("require-trusted-types-for 'script'"),
399            DirectiveValue::Token(Some("'script'".to_string()))
400        );
401        assert_eq!(
402            directive_value("trusted-types my-policy 'allow-duplicates'"),
403            DirectiveValue::TrustedTypes(vec![
404                "my-policy".to_string(),
405                "'allow-duplicates'".to_string(),
406            ])
407        );
408    }
409
410    #[test]
411    fn plugin_types_is_deprecated_token_list() {
412        assert_eq!(
413            directive_value("plugin-types application/pdf"),
414            DirectiveValue::TokenList(vec!["application/pdf".to_string()])
415        );
416        assert_eq!(
417            registry_lookup("plugin-types").map(|(_, status)| status),
418            Some(DirectiveStatus::Deprecated)
419        );
420    }
421
422    #[test]
423    fn unknown_directive_stays_syntactically_valid_but_unstructured() {
424        let list = parse_policy_list("default-src 'self'; totally-unknown-directive foo");
425        assert_eq!(list.policies[0].directives.len(), 2);
426        assert_eq!(
427            list.policies[0].directives[1].value(),
428            DirectiveValue::Unknown
429        );
430    }
431
432    #[test]
433    fn registry_lookup_is_case_insensitive() {
434        assert!(registry_lookup("Default-Src").is_some());
435        assert!(registry_lookup("DEFAULT-SRC").is_some());
436    }
437}