Skip to main content

hpx_browser/net/
csp.rs

1//! Content Security Policy (CSP3) parser, matcher, and per-fetch enforcement.
2//!
3//! Uses `winnow` combinators for parsing. Implements the subset of CSP3
4//! needed for network-path enforcement: fetch directives, source-list
5//! keywords, host/scheme/nonce/hash sources, default-src fallback,
6//! strict-dynamic semantics.
7
8use std::collections::HashMap;
9
10use url::Url;
11use winnow::{ascii::multispace0, prelude::*, token::take_while};
12
13// ---------------------------------------------------------------------
14// Directive
15// ---------------------------------------------------------------------
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum Directive {
19    DefaultSrc,
20    ScriptSrc,
21    ScriptSrcElem,
22    ScriptSrcAttr,
23    StyleSrc,
24    StyleSrcElem,
25    StyleSrcAttr,
26    ImgSrc,
27    ConnectSrc,
28    FrameSrc,
29    ChildSrc,
30    FontSrc,
31    MediaSrc,
32    ObjectSrc,
33    WorkerSrc,
34    ManifestSrc,
35    PrefetchSrc,
36}
37
38const DIRECTIVE_NAMES: &[(&str, Directive)] = &[
39    ("default-src", Directive::DefaultSrc),
40    ("script-src-elem", Directive::ScriptSrcElem),
41    ("script-src-attr", Directive::ScriptSrcAttr),
42    ("script-src", Directive::ScriptSrc),
43    ("style-src-elem", Directive::StyleSrcElem),
44    ("style-src-attr", Directive::StyleSrcAttr),
45    ("style-src", Directive::StyleSrc),
46    ("img-src", Directive::ImgSrc),
47    ("connect-src", Directive::ConnectSrc),
48    ("frame-src", Directive::FrameSrc),
49    ("child-src", Directive::ChildSrc),
50    ("font-src", Directive::FontSrc),
51    ("media-src", Directive::MediaSrc),
52    ("object-src", Directive::ObjectSrc),
53    ("worker-src", Directive::WorkerSrc),
54    ("manifest-src", Directive::ManifestSrc),
55    ("prefetch-src", Directive::PrefetchSrc),
56];
57
58impl Directive {
59    pub fn from_token(s: &str) -> Option<Self> {
60        let lower = s.to_ascii_lowercase();
61        DIRECTIVE_NAMES
62            .iter()
63            .find(|(name, _)| *name == lower.as_str())
64            .map(|(_, d)| *d)
65    }
66
67    pub fn as_str(&self) -> &'static str {
68        for (name, d) in DIRECTIVE_NAMES {
69            if d == self {
70                return name;
71            }
72        }
73        unreachable!()
74    }
75
76    /// CSP3 section 6.6.1.1 — fallback chain for fetch directives.
77    pub fn fallback_chain(&self) -> &'static [Directive] {
78        use Directive::*;
79        match self {
80            ScriptSrcElem => &[ScriptSrcElem, ScriptSrc, DefaultSrc],
81            ScriptSrcAttr => &[ScriptSrcAttr, ScriptSrc, DefaultSrc],
82            ScriptSrc => &[ScriptSrc, DefaultSrc],
83            StyleSrcElem => &[StyleSrcElem, StyleSrc, DefaultSrc],
84            StyleSrcAttr => &[StyleSrcAttr, StyleSrc, DefaultSrc],
85            StyleSrc => &[StyleSrc, DefaultSrc],
86            FrameSrc => &[FrameSrc, ChildSrc, DefaultSrc],
87            ChildSrc => &[ChildSrc, DefaultSrc],
88            WorkerSrc => &[WorkerSrc, ChildSrc, ScriptSrc, DefaultSrc],
89            ImgSrc | ConnectSrc | FontSrc | MediaSrc | ObjectSrc | ManifestSrc | PrefetchSrc => {
90                // ponytail: single-element array via const; avoids per-call alloc
91                const CHAIN: [Directive; 1] = [Directive::DefaultSrc];
92                match self {
93                    ImgSrc => &[ImgSrc, DefaultSrc],
94                    ConnectSrc => &[ConnectSrc, DefaultSrc],
95                    FontSrc => &[FontSrc, DefaultSrc],
96                    MediaSrc => &[MediaSrc, DefaultSrc],
97                    ObjectSrc => &[ObjectSrc, DefaultSrc],
98                    ManifestSrc => &[ManifestSrc, DefaultSrc],
99                    PrefetchSrc => &[PrefetchSrc, DefaultSrc],
100                    _ => &CHAIN,
101                }
102            }
103            DefaultSrc => &[DefaultSrc],
104        }
105    }
106}
107
108// ---------------------------------------------------------------------
109// Source
110// ---------------------------------------------------------------------
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum HashAlgo {
114    Sha256,
115    Sha384,
116    Sha512,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Source {
121    All,
122    None_,
123    Self_,
124    UnsafeInline,
125    UnsafeEval,
126    UnsafeHashes,
127    StrictDynamic,
128    ReportSample,
129    Scheme(String),
130    Host(HostSource),
131    Nonce(String),
132    Hash(HashAlgo, String),
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct HostSource {
137    pub scheme: Option<String>,
138    pub host: HostPattern,
139    pub port: Option<PortPattern>,
140    pub path: Option<String>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum HostPattern {
145    Wildcard(String),
146    Exact(String),
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum PortPattern {
151    Wildcard,
152    Exact(u16),
153}
154
155// ---------------------------------------------------------------------
156// Winnow parsers (winnow 1.x API)
157// ---------------------------------------------------------------------
158
159type PResult<T> = winnow::Result<T, winnow::error::ContextError>;
160
161fn parse_directive_name(input: &mut &str) -> PResult<Directive> {
162    let name: &str =
163        take_while(1.., |c: char| c.is_ascii_lowercase() || c == '-').parse_next(input)?;
164    Directive::from_token(name).ok_or_else(winnow::error::ContextError::new)
165}
166
167fn parse_nonce_source(input: &mut &str) -> PResult<Source> {
168    let _ = '\''.parse_next(input)?;
169    let _ = "nonce-".parse_next(input)?;
170    let value: &str = take_while(1.., |c: char| c != '\'').parse_next(input)?;
171    let _ = '\''.parse_next(input)?;
172    Ok(Source::Nonce(value.to_string()))
173}
174
175fn parse_hash_source(input: &mut &str) -> PResult<Source> {
176    let _ = '\''.parse_next(input)?;
177    let algo: &str = take_while(1.., |c: char| c.is_ascii_alphanumeric()).parse_next(input)?;
178    let hash_algo = match algo {
179        "sha256" => HashAlgo::Sha256,
180        "sha384" => HashAlgo::Sha384,
181        "sha512" => HashAlgo::Sha512,
182        _ => return Err(winnow::error::ContextError::new()),
183    };
184    let _ = '-'.parse_next(input)?;
185    let value: &str = take_while(1.., |c: char| c != '\'').parse_next(input)?;
186    let _ = '\''.parse_next(input)?;
187    Ok(Source::Hash(hash_algo, value.to_string()))
188}
189
190fn parse_host_pattern(raw: &str) -> HostPattern {
191    if let Some(suffix) = raw.strip_prefix("*.") {
192        HostPattern::Wildcard(suffix.to_ascii_lowercase())
193    } else {
194        HostPattern::Exact(raw.to_ascii_lowercase())
195    }
196}
197
198fn parse_host_source_token(token: &str) -> Option<Source> {
199    if token.starts_with('\'') {
200        return None;
201    }
202    let mut rest = token;
203
204    let mut scheme = None;
205    if let Some(idx) = rest.find("://") {
206        let s = &rest[..idx];
207        if !s.is_empty()
208            && s.chars()
209                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
210        {
211            scheme = Some(s.to_ascii_lowercase());
212            rest = &rest[idx + 3..];
213        }
214    }
215
216    let (host_port, path) = match rest.find('/') {
217        Some(idx) => (&rest[..idx], Some(rest[idx..].to_string())),
218        None => (rest, None),
219    };
220
221    if host_port.is_empty() {
222        return None;
223    }
224
225    let (host_part, port) = match host_port.rfind(':') {
226        Some(idx) => {
227            let port_str = &host_port[idx + 1..];
228            if port_str == "*" {
229                (&host_port[..idx], Some(PortPattern::Wildcard))
230            } else if let Ok(n) = port_str.parse::<u16>() {
231                (&host_port[..idx], Some(PortPattern::Exact(n)))
232            } else {
233                (host_port, None)
234            }
235        }
236        None => (host_port, None),
237    };
238
239    if host_part.is_empty() {
240        return None;
241    }
242    // Reject wildcards not at the start (e.g., "foo*bar").
243    if host_part.contains('*') && !host_part.starts_with("*.") {
244        return None;
245    }
246    // Validate host chars (strip leading wildcard before checking).
247    let host_check = host_part.strip_prefix("*.").unwrap_or(host_part);
248    if host_check != "*"
249        && !host_check
250            .chars()
251            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
252    {
253        return None;
254    }
255
256    Some(Source::Host(HostSource {
257        scheme,
258        host: parse_host_pattern(host_part),
259        port,
260        path,
261    }))
262}
263
264fn parse_source_token(input: &mut &str) -> PResult<Source> {
265    let token: &str = take_while(1.., |c: char| !c.is_whitespace()).parse_next(input)?;
266
267    let lower = token.to_ascii_lowercase();
268    match lower.as_str() {
269        "*" => return Ok(Source::All),
270        "'none'" => return Ok(Source::None_),
271        "'self'" => return Ok(Source::Self_),
272        "'unsafe-inline'" => return Ok(Source::UnsafeInline),
273        "'unsafe-eval'" => return Ok(Source::UnsafeEval),
274        "'unsafe-hashes'" => return Ok(Source::UnsafeHashes),
275        "'strict-dynamic'" => return Ok(Source::StrictDynamic),
276        "'report-sample'" => return Ok(Source::ReportSample),
277        _ => {}
278    }
279
280    // Nonce
281    if let Some(rest) = token.strip_prefix("'nonce-") {
282        if let Some(value) = rest.strip_suffix('\'') {
283            return Ok(Source::Nonce(value.to_string()));
284        }
285    }
286
287    // Hash
288    for (algo, prefix) in [
289        (HashAlgo::Sha256, "'sha256-"),
290        (HashAlgo::Sha384, "'sha384-"),
291        (HashAlgo::Sha512, "'sha512-"),
292    ] {
293        if let Some(rest) = token.strip_prefix(prefix) {
294            if let Some(value) = rest.strip_suffix('\'') {
295                return Ok(Source::Hash(algo, value.to_string()));
296            }
297        }
298    }
299
300    // Scheme-only: ends with ':'
301    if let Some(scheme) = token.strip_suffix(':') {
302        if !scheme.contains('/')
303            && scheme
304                .chars()
305                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
306        {
307            return Ok(Source::Scheme(scheme.to_ascii_lowercase()));
308        }
309    }
310
311    // Host source
312    if let Some(src) = parse_host_source_token(token) {
313        return Ok(src);
314    }
315
316    Err(winnow::error::ContextError::new())
317}
318
319fn parse_source_list(input: &mut &str) -> PResult<Vec<Source>> {
320    let mut sources = Vec::new();
321    loop {
322        let _ = multispace0.parse_next(input)?;
323        if input.is_empty() {
324            break;
325        }
326        // Check if the next char starts a new directive (letter) or is a semicolon.
327        if input.starts_with(';') {
328            break;
329        }
330        match parse_source_token.parse_next(input) {
331            Ok(src) => sources.push(src),
332            Err(_) => break,
333        }
334    }
335    Ok(sources)
336}
337
338fn parse_directive_line(input: &mut &str) -> PResult<(Directive, Vec<Source>)> {
339    let _ = multispace0.parse_next(input)?;
340    let directive = parse_directive_name.parse_next(input)?;
341    let _ = multispace0.parse_next(input)?;
342    let sources = parse_source_list.parse_next(input)?;
343    Ok((directive, sources))
344}
345
346fn parse_policy_string(input: &mut &str) -> PResult<Vec<(Directive, Vec<Source>)>> {
347    let mut pairs = Vec::new();
348    loop {
349        let _ = multispace0.parse_next(input)?;
350        if input.is_empty() {
351            break;
352        }
353        match parse_directive_line.parse_next(input) {
354            Ok(pair) => pairs.push(pair),
355            Err(_) => {
356                // Skip unknown directive — consume until next semicolon or end.
357                if let Some(idx) = input.find(';') {
358                    *input = &input[idx + 1..];
359                } else {
360                    *input = &input[input.len()..];
361                }
362            }
363        }
364        // Skip separator
365        let _ = multispace0.parse_next(input)?;
366        if input.starts_with(';') {
367            let _ = ';'.parse_next(input)?;
368        }
369    }
370    Ok(pairs)
371}
372
373// ---------------------------------------------------------------------
374// Policy / PolicySet
375// ---------------------------------------------------------------------
376
377#[derive(Debug, Clone, Default)]
378pub struct Policy {
379    pub directives: HashMap<Directive, Vec<Source>>,
380    pub report_only: bool,
381}
382
383#[derive(Debug, Clone, Default)]
384pub struct PolicySet {
385    pub policies: Vec<Policy>,
386}
387
388impl PolicySet {
389    pub fn is_empty(&self) -> bool {
390        self.policies.iter().all(|p| p.directives.is_empty())
391    }
392
393    /// Parse and push one or more policies from a header value.
394    pub fn push_header(&mut self, value: &str, report_only: bool) {
395        for piece in value.split(',').map(str::trim).filter(|p| !p.is_empty()) {
396            let policy = Policy::parse_serialized(piece, report_only);
397            if !policy.directives.is_empty() {
398                self.policies.push(policy);
399            }
400        }
401    }
402
403    /// Parse and push a policy from a `<meta>` content attribute.
404    pub fn push_meta(&mut self, content: &str) {
405        for piece in content.split(',').map(str::trim).filter(|p| !p.is_empty()) {
406            let policy = Policy::parse_serialized(piece, false);
407            if !policy.directives.is_empty() {
408                self.policies.push(policy);
409            }
410        }
411    }
412}
413
414impl Policy {
415    /// Parse a single serialized policy using winnow combinators.
416    pub fn parse_serialized(s: &str, report_only: bool) -> Policy {
417        let mut input = s;
418        let pairs = parse_policy_string
419            .parse_next(&mut input)
420            .unwrap_or_default();
421        let mut directives: HashMap<Directive, Vec<Source>> = HashMap::new();
422        for (dir, sources) in pairs {
423            directives.entry(dir).or_default().extend(sources);
424        }
425        Policy {
426            directives,
427            report_only,
428        }
429    }
430
431    pub fn parse_header(s: &str) -> PolicySet {
432        let mut set = PolicySet::default();
433        set.push_header(s, false);
434        set
435    }
436
437    pub fn parse_meta_content(s: &str) -> PolicySet {
438        let mut set = PolicySet::default();
439        set.push_meta(s);
440        set
441    }
442}
443
444// ---------------------------------------------------------------------
445// CheckCtx / AllowDecision / Matcher
446// ---------------------------------------------------------------------
447
448#[derive(Debug, Clone)]
449pub struct CheckCtx<'a> {
450    pub directive: Directive,
451    pub url: &'a Url,
452    pub page_origin: &'a Url,
453    pub nonce: Option<&'a str>,
454    pub parser_inserted: bool,
455}
456
457#[derive(Debug, Clone)]
458pub struct AllowDecision {
459    pub allowed: bool,
460    pub matched_directive: Directive,
461    pub report_only: bool,
462}
463
464impl AllowDecision {
465    pub fn allow_no_policy() -> Self {
466        Self {
467            allowed: true,
468            matched_directive: Directive::DefaultSrc,
469            report_only: false,
470        }
471    }
472}
473
474impl PolicySet {
475    /// Returns ALLOW only if every enforced policy allows.
476    pub fn allows(&self, ctx: &CheckCtx<'_>) -> AllowDecision {
477        if self.policies.is_empty() {
478            return AllowDecision::allow_no_policy();
479        }
480        for policy in &self.policies {
481            let decision = policy.allows(ctx);
482            if !decision.allowed && !policy.report_only {
483                return decision;
484            }
485        }
486        AllowDecision::allow_no_policy()
487    }
488}
489
490impl Policy {
491    pub fn allows(&self, ctx: &CheckCtx<'_>) -> AllowDecision {
492        for &candidate in ctx.directive.fallback_chain() {
493            if let Some(sources) = self.directives.get(&candidate) {
494                let allowed = match_sources(sources, ctx);
495                return AllowDecision {
496                    allowed,
497                    matched_directive: candidate,
498                    report_only: self.report_only,
499                };
500            }
501        }
502        AllowDecision::allow_no_policy()
503    }
504}
505
506fn match_sources(sources: &[Source], ctx: &CheckCtx<'_>) -> bool {
507    if sources.is_empty() {
508        return false;
509    }
510    if sources.iter().all(|s| matches!(s, Source::None_)) {
511        return false;
512    }
513
514    let strict_dynamic = is_script_directive(ctx.directive)
515        && sources.iter().any(|s| matches!(s, Source::StrictDynamic));
516
517    for src in sources {
518        match src {
519            Source::None_
520            | Source::UnsafeInline
521            | Source::UnsafeEval
522            | Source::UnsafeHashes
523            | Source::ReportSample
524            | Source::StrictDynamic => continue,
525
526            Source::All if !strict_dynamic => {
527                if is_network_scheme(ctx.url.scheme()) {
528                    return true;
529                }
530            }
531            Source::All => continue,
532
533            Source::Self_ if !strict_dynamic => {
534                if is_same_origin(ctx.url, ctx.page_origin) {
535                    return true;
536                }
537            }
538            Source::Self_ => continue,
539
540            Source::Scheme(s) if !strict_dynamic => {
541                if ctx.url.scheme().eq_ignore_ascii_case(s) {
542                    return true;
543                }
544            }
545            Source::Scheme(_) => continue,
546
547            Source::Host(h) if !strict_dynamic => {
548                if host_source_matches(h, ctx.url) {
549                    return true;
550                }
551            }
552            Source::Host(_) => continue,
553
554            Source::Nonce(token) => {
555                if let Some(supplied) = ctx.nonce {
556                    if supplied == token {
557                        return true;
558                    }
559                }
560            }
561
562            Source::Hash(_, _) => continue,
563        }
564    }
565    false
566}
567
568fn is_script_directive(d: Directive) -> bool {
569    matches!(
570        d,
571        Directive::ScriptSrc | Directive::ScriptSrcElem | Directive::ScriptSrcAttr
572    )
573}
574
575fn is_network_scheme(scheme: &str) -> bool {
576    matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "ftps")
577}
578
579fn is_same_origin(a: &Url, b: &Url) -> bool {
580    a.scheme() == b.scheme()
581        && a.host_str() == b.host_str()
582        && a.port_or_known_default() == b.port_or_known_default()
583}
584
585fn host_source_matches(src: &HostSource, url: &Url) -> bool {
586    if let Some(want) = &src.scheme {
587        if !url.scheme().eq_ignore_ascii_case(want) {
588            return false;
589        }
590    } else if !is_network_scheme(url.scheme()) {
591        return false;
592    }
593
594    let url_host = match url.host_str() {
595        Some(h) => h.to_ascii_lowercase(),
596        None => return false,
597    };
598    let host_ok = match &src.host {
599        HostPattern::Exact(want) => want == "*" || want == &url_host,
600        HostPattern::Wildcard(suffix) => {
601            url_host.ends_with(suffix)
602                && url_host.len() > suffix.len()
603                && url_host.chars().nth(url_host.len() - suffix.len() - 1) == Some('.')
604        }
605    };
606    if !host_ok {
607        return false;
608    }
609
610    let url_port = url.port_or_known_default();
611    if let Some(p) = &src.port {
612        match p {
613            PortPattern::Wildcard => {}
614            PortPattern::Exact(n) => {
615                if url_port != Some(*n) {
616                    return false;
617                }
618            }
619        }
620    } else {
621        let default_port = match url.scheme() {
622            "http" | "ws" | "ftp" => Some(80),
623            "https" | "wss" | "ftps" => Some(443),
624            _ => None,
625        };
626        if url_port != default_port {
627            return false;
628        }
629    }
630
631    true
632}
633
634// ---------------------------------------------------------------------
635// Tests
636// ---------------------------------------------------------------------
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    const WALMART_CSP: &str = "child-src 'self' blob:; \
643        connect-src 'self' *.akamaihd.net *.perimeterx.net; \
644        script-src 'self' 'strict-dynamic' 'nonce-MRjHHgrLk9lNoNBv' *.walmartimages.com; \
645        style-src 'self' 'unsafe-inline' *.walmartimages.com; \
646        img-src 'self' data: *.walmartimages.com *.scene7.com; \
647        frame-src 'self' *.youtube.com";
648
649    fn url(s: &str) -> Url {
650        Url::parse(s).unwrap()
651    }
652
653    fn ctx<'a>(
654        directive: Directive,
655        u: &'a Url,
656        origin: &'a Url,
657        nonce: Option<&'a str>,
658        parser_inserted: bool,
659    ) -> CheckCtx<'a> {
660        CheckCtx {
661            directive,
662            url: u,
663            page_origin: origin,
664            nonce,
665            parser_inserted,
666        }
667    }
668
669    #[test]
670    fn parses_retailer_csp_directives() {
671        let set = Policy::parse_meta_content(WALMART_CSP);
672        assert_eq!(set.policies.len(), 1);
673        let p = &set.policies[0];
674        assert!(!p.report_only);
675        assert!(p.directives.contains_key(&Directive::ScriptSrc));
676        assert!(p.directives.contains_key(&Directive::ConnectSrc));
677        assert!(p.directives.contains_key(&Directive::FrameSrc));
678    }
679
680    #[test]
681    fn parses_strict_dynamic_and_nonce() {
682        let set = Policy::parse_meta_content(WALMART_CSP);
683        let script_src = &set.policies[0].directives[&Directive::ScriptSrc];
684        assert!(
685            script_src
686                .iter()
687                .any(|s| matches!(s, Source::StrictDynamic))
688        );
689        assert!(
690            script_src
691                .iter()
692                .any(|s| matches!(s, Source::Nonce(n) if n == "MRjHHgrLk9lNoNBv"))
693        );
694        assert!(script_src.iter().any(|s| matches!(s, Source::Self_)));
695    }
696
697    #[test]
698    fn parses_host_source_with_subdomain_wildcard() {
699        let set = Policy::parse_meta_content("connect-src *.example.com:8443");
700        let cs = &set.policies[0].directives[&Directive::ConnectSrc];
701        assert_eq!(cs.len(), 1);
702        let Source::Host(h) = &cs[0] else {
703            panic!("expected host source")
704        };
705        assert_eq!(h.host, HostPattern::Wildcard("example.com".to_string()));
706        assert_eq!(h.port, Some(PortPattern::Exact(8443)));
707    }
708
709    #[test]
710    fn parses_scheme_only_source() {
711        let set = Policy::parse_meta_content("img-src data: blob: https:");
712        let img = &set.policies[0].directives[&Directive::ImgSrc];
713        assert_eq!(img.len(), 3);
714        assert!(
715            img.iter()
716                .any(|s| matches!(s, Source::Scheme(x) if x == "data"))
717        );
718        assert!(
719            img.iter()
720                .any(|s| matches!(s, Source::Scheme(x) if x == "blob"))
721        );
722        assert!(
723            img.iter()
724                .any(|s| matches!(s, Source::Scheme(x) if x == "https"))
725        );
726    }
727
728    #[test]
729    fn parses_hash_sources() {
730        let set =
731            Policy::parse_meta_content("script-src 'sha256-abc123==' 'sha384-XYZ' 'sha512-q+w'");
732        let ss = &set.policies[0].directives[&Directive::ScriptSrc];
733        assert_eq!(ss.len(), 3);
734        assert!(matches!(&ss[0], Source::Hash(HashAlgo::Sha256, h) if h == "abc123=="));
735        assert!(matches!(&ss[1], Source::Hash(HashAlgo::Sha384, h) if h == "XYZ"));
736        assert!(matches!(&ss[2], Source::Hash(HashAlgo::Sha512, h) if h == "q+w"));
737    }
738
739    #[test]
740    fn parses_none_keyword() {
741        let set = Policy::parse_meta_content("object-src 'none'");
742        let os = &set.policies[0].directives[&Directive::ObjectSrc];
743        assert_eq!(os.len(), 1);
744        assert!(matches!(&os[0], Source::None_));
745    }
746
747    #[test]
748    fn parses_multiple_policies_from_one_header() {
749        let mut set = PolicySet::default();
750        set.push_header("script-src 'self', script-src https:", false);
751        assert_eq!(set.policies.len(), 2);
752    }
753
754    #[test]
755    fn report_only_flag_propagates() {
756        let mut set = PolicySet::default();
757        set.push_header("script-src 'self'", true);
758        assert!(set.policies[0].report_only);
759    }
760
761    #[test]
762    fn unknown_directive_is_dropped() {
763        let set = Policy::parse_meta_content("script-src 'self'; bogus-thing 'self'");
764        assert_eq!(set.policies[0].directives.len(), 1);
765    }
766
767    #[test]
768    fn fallback_chain_script_src_elem() {
769        let chain = Directive::ScriptSrcElem.fallback_chain();
770        assert_eq!(
771            chain,
772            &[
773                Directive::ScriptSrcElem,
774                Directive::ScriptSrc,
775                Directive::DefaultSrc
776            ]
777        );
778    }
779
780    #[test]
781    fn fallback_chain_frame_src() {
782        let chain = Directive::FrameSrc.fallback_chain();
783        assert_eq!(
784            chain,
785            &[
786                Directive::FrameSrc,
787                Directive::ChildSrc,
788                Directive::DefaultSrc
789            ]
790        );
791    }
792
793    #[test]
794    fn empty_policy_set_allows_everything() {
795        let set = PolicySet::default();
796        let u = url("https://akamai.com/sensor.js");
797        let origin = url("https://www.walmart.com/");
798        let d = set.allows(&ctx(Directive::ScriptSrcElem, &u, &origin, None, true));
799        assert!(d.allowed);
800    }
801
802    #[test]
803    fn self_matches_same_origin() {
804        let set = Policy::parse_meta_content("script-src 'self'");
805        let origin = url("https://example.com/");
806        let same = url("https://example.com/app.js");
807        let other = url("https://other.com/x.js");
808        assert!(
809            set.allows(&ctx(Directive::ScriptSrcElem, &same, &origin, None, true))
810                .allowed
811        );
812        assert!(
813            !set.allows(&ctx(Directive::ScriptSrcElem, &other, &origin, None, true))
814                .allowed
815        );
816    }
817
818    #[test]
819    fn host_wildcard_matches_subdomain_only() {
820        let set = Policy::parse_meta_content("img-src *.example.com");
821        let origin = url("https://example.com/");
822        let sub = url("https://images.example.com/a.png");
823        let bare = url("https://example.com/a.png");
824        assert!(
825            set.allows(&ctx(Directive::ImgSrc, &sub, &origin, None, false))
826                .allowed
827        );
828        assert!(
829            !set.allows(&ctx(Directive::ImgSrc, &bare, &origin, None, false))
830                .allowed
831        );
832    }
833
834    #[test]
835    fn scheme_only_source_matches() {
836        let set = Policy::parse_meta_content("img-src data: https:");
837        let origin = url("https://example.com/");
838        let data = url("data:image/png;base64,iVBORw0K");
839        let any_https = url("https://random.cdn.net/x.png");
840        let http = url("http://random.cdn.net/x.png");
841        assert!(
842            set.allows(&ctx(Directive::ImgSrc, &data, &origin, None, false))
843                .allowed
844        );
845        assert!(
846            set.allows(&ctx(Directive::ImgSrc, &any_https, &origin, None, false))
847                .allowed
848        );
849        assert!(
850            !set.allows(&ctx(Directive::ImgSrc, &http, &origin, None, false))
851                .allowed
852        );
853    }
854
855    #[test]
856    fn none_blocks_everything() {
857        let set = Policy::parse_meta_content("object-src 'none'");
858        let origin = url("https://example.com/");
859        let any = url("https://example.com/x.swf");
860        assert!(
861            !set.allows(&ctx(Directive::ObjectSrc, &any, &origin, None, false))
862                .allowed
863        );
864    }
865
866    #[test]
867    fn fallback_chain_uses_default_src() {
868        let set = Policy::parse_meta_content("default-src 'self'");
869        let origin = url("https://example.com/");
870        let self_url = url("https://example.com/x.png");
871        let other = url("https://other.com/x.png");
872        assert!(
873            set.allows(&ctx(Directive::ImgSrc, &self_url, &origin, None, false))
874                .allowed
875        );
876        assert!(
877            !set.allows(&ctx(Directive::ImgSrc, &other, &origin, None, false))
878                .allowed
879        );
880    }
881
882    #[test]
883    fn nonce_authorizes_under_normal_policy() {
884        let set = Policy::parse_meta_content("script-src 'nonce-abc123'");
885        let origin = url("https://example.com/");
886        let any = url("https://cdn.elsewhere.com/app.js");
887        assert!(
888            set.allows(&ctx(
889                Directive::ScriptSrcElem,
890                &any,
891                &origin,
892                Some("abc123"),
893                true
894            ))
895            .allowed
896        );
897        assert!(
898            !set.allows(&ctx(
899                Directive::ScriptSrcElem,
900                &any,
901                &origin,
902                Some("WRONG"),
903                true
904            ))
905            .allowed
906        );
907    }
908
909    #[test]
910    fn strict_dynamic_blocks_parser_injected_script() {
911        let set = Policy::parse_meta_content(WALMART_CSP);
912        let origin = url("https://www.walmart.com/");
913        let injected = url("https://www.walmart.com/akam/13/3e35295b");
914
915        let d = set.allows(&ctx(
916            Directive::ScriptSrcElem,
917            &injected,
918            &origin,
919            None,
920            true,
921        ));
922        assert!(!d.allowed);
923        assert_eq!(d.matched_directive, Directive::ScriptSrc);
924
925        let d = set.allows(&ctx(
926            Directive::ScriptSrcElem,
927            &injected,
928            &origin,
929            Some("MRjHHgrLk9lNoNBv"),
930            true,
931        ));
932        assert!(d.allowed);
933    }
934
935    #[test]
936    fn strict_dynamic_ignores_self_and_host_allowlist() {
937        let set = Policy::parse_meta_content(WALMART_CSP);
938        let origin = url("https://www.walmart.com/");
939        let images = url("https://i5.walmartimages.com/foo.js");
940        assert!(
941            !set.allows(&ctx(Directive::ScriptSrcElem, &images, &origin, None, true))
942                .allowed
943        );
944        assert!(
945            set.allows(&ctx(
946                Directive::ScriptSrcElem,
947                &images,
948                &origin,
949                Some("MRjHHgrLk9lNoNBv"),
950                true,
951            ))
952            .allowed
953        );
954    }
955
956    #[test]
957    fn strict_dynamic_does_not_apply_to_non_script() {
958        let set = Policy::parse_meta_content(WALMART_CSP);
959        let origin = url("https://www.walmart.com/");
960        let img = url("https://i5.walmartimages.com/foo.png");
961        assert!(
962            set.allows(&ctx(Directive::ImgSrc, &img, &origin, None, false))
963                .allowed
964        );
965    }
966
967    #[test]
968    fn host_with_wildcard_port_matches_any() {
969        let set = Policy::parse_meta_content("connect-src example.com:*");
970        let origin = url("https://other.com/");
971        let p443 = url("https://example.com/x");
972        let p8443 = url("https://example.com:8443/x");
973        assert!(
974            set.allows(&ctx(Directive::ConnectSrc, &p443, &origin, None, false))
975                .allowed
976        );
977        assert!(
978            set.allows(&ctx(Directive::ConnectSrc, &p8443, &origin, None, false))
979                .allowed
980        );
981    }
982
983    #[test]
984    fn host_without_port_matches_only_default_port() {
985        let set = Policy::parse_meta_content("connect-src example.com");
986        let origin = url("https://other.com/");
987        let p443 = url("https://example.com/x");
988        let p8443 = url("https://example.com:8443/x");
989        assert!(
990            set.allows(&ctx(Directive::ConnectSrc, &p443, &origin, None, false))
991                .allowed
992        );
993        assert!(
994            !set.allows(&ctx(Directive::ConnectSrc, &p8443, &origin, None, false))
995                .allowed
996        );
997    }
998
999    #[test]
1000    fn report_only_policy_never_blocks() {
1001        let mut set = PolicySet::default();
1002        set.push_header("script-src 'none'", true);
1003        let origin = url("https://example.com/");
1004        let any = url("https://example.com/x.js");
1005        assert!(
1006            set.allows(&ctx(Directive::ScriptSrcElem, &any, &origin, None, true))
1007                .allowed
1008        );
1009    }
1010
1011    #[test]
1012    fn multiple_policies_intersect() {
1013        let mut set = PolicySet::default();
1014        set.push_header("script-src 'self' https://cdn.com", false);
1015        set.push_header("script-src 'self'", false);
1016        let origin = url("https://example.com/");
1017        let cdn = url("https://cdn.com/x.js");
1018        let self_url = url("https://example.com/x.js");
1019        assert!(
1020            !set.allows(&ctx(Directive::ScriptSrcElem, &cdn, &origin, None, true))
1021                .allowed
1022        );
1023        assert!(
1024            set.allows(&ctx(
1025                Directive::ScriptSrcElem,
1026                &self_url,
1027                &origin,
1028                None,
1029                true
1030            ))
1031            .allowed
1032        );
1033    }
1034
1035    #[test]
1036    fn parses_bare_star_host() {
1037        let set = Policy::parse_meta_content("img-src *");
1038        let img = &set.policies[0].directives[&Directive::ImgSrc];
1039        assert!(matches!(&img[0], Source::All));
1040    }
1041
1042    #[test]
1043    fn empty_source_list_blocks() {
1044        let set = Policy::parse_meta_content("script-src");
1045        let ss = &set.policies[0].directives[&Directive::ScriptSrc];
1046        assert_eq!(ss.len(), 0);
1047    }
1048
1049    // ---- Property tests: parse arbitrary CSP headers without panic ----
1050
1051    #[test]
1052    fn prop_parse_arbitrary_csp_strings_no_panic() {
1053        let cases = [
1054            "",
1055            ";",
1056            ";;;",
1057            "script-src",
1058            "script-src 'self'",
1059            "default-src *; script-src 'none'",
1060            "img-src data: blob: https: http:",
1061            "connect-src *.example.com:443 wss://ws.example.com",
1062            "script-src 'nonce-abc' 'sha256-hash' 'strict-dynamic'",
1063            "font-src 'self' https://fonts.gstatic.com",
1064            "object-src 'none'; frame-src 'self' https://www.youtube.com",
1065            "style-src 'self' 'unsafe-inline'; style-src-elem 'self'",
1066            "worker-src blob: 'self'; child-src blob:",
1067            "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'",
1068            "prefetch-src 'self'; manifest-src 'self'",
1069            "script-src 'self' *.cdn.example.com:8443/path",
1070            "img-src https: http: data: blob:",
1071            "connect-src *",
1072            "script-src 'sha256-abc123==' 'sha384-XYZ' 'sha512-q+w'",
1073            "default-src 'none'; script-src 'nonce-base64token==' 'self'",
1074            // Edge cases
1075            "bogus-directive 'self'",
1076            "script-src 'nonce-",
1077            "script-src 'sha256-",
1078            "script-src bad://host",
1079            "img-src *:8080",
1080            "connect-src example.com:*",
1081            "script-src 'nonce-' 'sha256-' 'unknown-keyword'",
1082            ";;; script-src;;; ;;;",
1083            &format!("script-src {}", "a ".repeat(200)),
1084        ];
1085
1086        for input in &cases {
1087            let set = Policy::parse_meta_content(input);
1088            // Must not panic; also exercise the matcher.
1089            let origin = url("https://example.com/");
1090            let test_url = url("https://example.com/x");
1091            let _ = set.allows(&ctx(
1092                Directive::ScriptSrcElem,
1093                &test_url,
1094                &origin,
1095                None,
1096                true,
1097            ));
1098        }
1099    }
1100
1101    #[cfg(feature = "proptest")]
1102    mod proptest_tests {
1103        use proptest::prelude::*;
1104
1105        use super::*;
1106
1107        proptest! {
1108            #[test]
1109            fn parse_arbitrary_csp_does_not_panic(s in ".*{0,500}") {
1110                let set = Policy::parse_meta_content(&s);
1111                let origin = url("https://example.com/");
1112                let test_url = url("https://example.com/x");
1113                let _ = set.allows(&ctx(Directive::ScriptSrcElem, &test_url, &origin, None, true));
1114                let _ = set.allows(&ctx(Directive::ImgSrc, &test_url, &origin, None, false));
1115                let _ = set.allows(&ctx(Directive::ConnectSrc, &test_url, &origin, None, false));
1116            }
1117
1118            #[test]
1119            fn parse_directive_sources_no_panic(
1120                directive in "(default-src|script-src|img-src|connect-src|style-src|frame-src|font-src|object-src|worker-src|media-src|child-src|manifest-src|prefetch-src|script-src-elem|script-src-attr|style-src-elem|style-src-attr)",
1121                sources in "([^;]{0,200})"
1122            ) {
1123                let input = format!("{directive} {sources}");
1124                let set = Policy::parse_meta_content(&input);
1125                let origin = url("https://example.com/");
1126                let test_url = url("https://example.com/x");
1127                let _ = set.allows(&ctx(Directive::ScriptSrcElem, &test_url, &origin, Some("abc"), true));
1128            }
1129        }
1130    }
1131}