1use std::collections::HashMap;
9
10use url::Url;
11use winnow::{ascii::multispace0, prelude::*, token::take_while};
12
13#[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 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 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#[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
155type 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 if host_part.contains('*') && !host_part.starts_with("*.") {
244 return None;
245 }
246 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 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 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 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 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 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 if let Some(idx) = input.find(';') {
358 *input = &input[idx + 1..];
359 } else {
360 *input = &input[input.len()..];
361 }
362 }
363 }
364 let _ = multispace0.parse_next(input)?;
366 if input.starts_with(';') {
367 let _ = ';'.parse_next(input)?;
368 }
369 }
370 Ok(pairs)
371}
372
373#[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 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 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 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#[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 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#[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 #[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 "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 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}