1use serde::Deserialize;
63
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Mode {
68 #[default]
70 Extend,
71 Replace,
73}
74
75impl std::fmt::Display for Mode {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Mode::Extend => write!(f, "extend"),
79 Mode::Replace => write!(f, "replace"),
80 }
81 }
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Directive {
87 Connect,
88 Resource,
89 Frame,
90}
91
92#[derive(Clone, Debug, Default, Deserialize)]
97#[serde(default)]
98pub struct DirectivePolicy {
99 pub domains: Vec<String>,
100 pub mode: Mode,
101}
102
103impl DirectivePolicy {
104 pub fn strict() -> Self {
107 Self {
108 domains: Vec::new(),
109 mode: Mode::Replace,
110 }
111 }
112}
113
114#[derive(Clone, Debug, Default, Deserialize)]
121#[serde(default)]
122pub struct WidgetScoped {
123 #[serde(rename = "match")]
126 pub match_pattern: String,
127
128 #[serde(rename = "connectDomains")]
129 pub connect_domains: Vec<String>,
130 #[serde(rename = "connectDomainsMode")]
131 pub connect_domains_mode: Mode,
132
133 #[serde(rename = "resourceDomains")]
134 pub resource_domains: Vec<String>,
135 #[serde(rename = "resourceDomainsMode")]
136 pub resource_domains_mode: Mode,
137
138 #[serde(rename = "frameDomains")]
139 pub frame_domains: Vec<String>,
140 #[serde(rename = "frameDomainsMode")]
141 pub frame_domains_mode: Mode,
142}
143
144impl WidgetScoped {
145 fn for_directive(&self, d: Directive) -> (&[String], Mode) {
147 match d {
148 Directive::Connect => (&self.connect_domains, self.connect_domains_mode),
149 Directive::Resource => (&self.resource_domains, self.resource_domains_mode),
150 Directive::Frame => (&self.frame_domains, self.frame_domains_mode),
151 }
152 }
153}
154
155#[derive(Clone, Debug)]
157pub struct CspConfig {
158 pub connect_domains: DirectivePolicy,
159 pub resource_domains: DirectivePolicy,
160 pub frame_domains: DirectivePolicy,
161 pub widgets: Vec<WidgetScoped>,
162 pub public_widget_domain: Option<String>,
168}
169
170impl Default for CspConfig {
171 fn default() -> Self {
175 Self {
176 connect_domains: DirectivePolicy::default(),
177 resource_domains: DirectivePolicy::default(),
178 frame_domains: DirectivePolicy::strict(),
179 widgets: Vec::new(),
180 public_widget_domain: None,
181 }
182 }
183}
184
185pub(crate) fn is_public_proxy_origin(url: &str) -> bool {
191 !url.is_empty() && !url.contains("localhost") && !url.contains("127.0.0.1")
192}
193
194impl CspConfig {
195 fn policy(&self, d: Directive) -> &DirectivePolicy {
196 match d {
197 Directive::Connect => &self.connect_domains,
198 Directive::Resource => &self.resource_domains,
199 Directive::Frame => &self.frame_domains,
200 }
201 }
202}
203
204pub fn effective_domains(
216 cfg: &CspConfig,
217 directive: Directive,
218 resource_uri: Option<&str>,
219 upstream_domains: &[String],
220 upstream_host: &str,
221 proxy_url: &str,
222) -> Vec<String> {
223 let global = cfg.policy(directive);
224
225 let mut base: Vec<String> = if global.mode == Mode::Replace {
227 Vec::new()
228 } else {
229 upstream_domains
230 .iter()
231 .filter(|d| !is_self_reference(d, upstream_host))
232 .cloned()
233 .collect()
234 };
235
236 for d in &global.domains {
238 push_unique(&mut base, d);
239 }
240
241 if let Some(uri) = resource_uri {
244 for w in &cfg.widgets {
245 if !glob_match(&w.match_pattern, uri) {
246 continue;
247 }
248 let (domains, mode) = w.for_directive(directive);
249 if domains.is_empty() && mode == Mode::Extend {
250 continue;
251 }
252 if mode == Mode::Replace {
253 base = domains.to_vec();
254 } else {
255 for d in domains {
256 push_unique(&mut base, d);
257 }
258 }
259 }
260 }
261
262 let mut out = if directive == Directive::Frame || !is_public_proxy_origin(proxy_url) {
269 Vec::new()
270 } else {
271 vec![proxy_url.to_string()]
272 };
273 for d in base {
274 push_unique(&mut out, &d);
275 }
276 out
277}
278
279fn push_unique(list: &mut Vec<String>, value: &str) {
280 if !list.iter().any(|s| s == value) {
281 list.push(value.to_string());
282 }
283}
284
285fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
289 if domain.contains("localhost") || domain.contains("127.0.0.1") {
290 return true;
291 }
292 !upstream_host.is_empty() && domain.contains(upstream_host)
293}
294
295pub fn glob_match(pattern: &str, input: &str) -> bool {
298 glob_rec(pattern.as_bytes(), input.as_bytes())
299}
300
301fn glob_rec(p: &[u8], t: &[u8]) -> bool {
302 if p.is_empty() {
303 return t.is_empty();
304 }
305 if p[0] == b'*' {
306 if glob_rec(&p[1..], t) {
310 return true;
311 }
312 if !t.is_empty() {
313 return glob_rec(p, &t[1..]);
314 }
315 return false;
316 }
317 if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
318 return glob_rec(&p[1..], &t[1..]);
319 }
320 false
321}
322
323#[cfg(test)]
324#[allow(non_snake_case)]
325mod tests {
326 use super::*;
327
328 fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
331 DirectivePolicy {
332 domains: domains.iter().map(|s| s.to_string()).collect(),
333 mode,
334 }
335 }
336
337 fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
338 WidgetScoped {
339 match_pattern: pattern.to_string(),
340 connect_domains: connect.iter().map(|s| s.to_string()).collect(),
341 connect_domains_mode: mode,
342 ..Default::default()
343 }
344 }
345
346 fn domains(items: &[&str]) -> Vec<String> {
347 items.iter().map(|s| s.to_string()).collect()
348 }
349
350 #[test]
353 fn mode__deserialises_extend() {
354 let m: Mode = serde_json::from_str("\"extend\"").unwrap();
355 assert_eq!(m, Mode::Extend);
356 }
357
358 #[test]
359 fn mode__deserialises_replace() {
360 let m: Mode = serde_json::from_str("\"replace\"").unwrap();
361 assert_eq!(m, Mode::Replace);
362 }
363
364 #[test]
365 fn mode__default_is_extend() {
366 assert_eq!(Mode::default(), Mode::Extend);
367 }
368
369 #[test]
372 fn csp_config__default_strict_frames() {
373 let c = CspConfig::default();
374 assert_eq!(c.connect_domains.mode, Mode::Extend);
375 assert_eq!(c.resource_domains.mode, Mode::Extend);
376 assert_eq!(c.frame_domains.mode, Mode::Replace);
377 }
378
379 #[test]
382 fn effective__extend_keeps_external_drops_upstream_host() {
383 let cfg = CspConfig::default();
384 let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
385
386 let out = effective_domains(
387 &cfg,
388 Directive::Connect,
389 None,
390 &upstream,
391 "localhost:9000",
392 "https://proxy.example.com",
393 );
394
395 assert_eq!(
396 out,
397 domains(&["https://proxy.example.com", "https://api.external.com"])
398 );
399 }
400
401 #[test]
402 fn effective__extend_adds_global_domains() {
403 let cfg = CspConfig {
404 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
405 ..CspConfig::default()
406 };
407
408 let out = effective_domains(
409 &cfg,
410 Directive::Connect,
411 None,
412 &domains(&["https://api.external.com"]),
413 "upstream.internal",
414 "https://proxy.example.com",
415 );
416
417 assert_eq!(
418 out,
419 domains(&[
420 "https://proxy.example.com",
421 "https://api.external.com",
422 "https://api.mine.com",
423 ])
424 );
425 }
426
427 #[test]
430 fn effective__replace_ignores_upstream() {
431 let cfg = CspConfig {
432 connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
433 ..CspConfig::default()
434 };
435
436 let out = effective_domains(
437 &cfg,
438 Directive::Connect,
439 None,
440 &domains(&["https://api.external.com"]),
441 "upstream.internal",
442 "https://proxy.example.com",
443 );
444
445 assert_eq!(
446 out,
447 domains(&["https://proxy.example.com", "https://api.mine.com"])
448 );
449 }
450
451 #[test]
452 fn effective__replace_with_empty_global_leaves_only_proxy() {
453 let cfg = CspConfig {
454 connect_domains: policy(&[], Mode::Replace),
455 ..CspConfig::default()
456 };
457
458 let out = effective_domains(
459 &cfg,
460 Directive::Connect,
461 None,
462 &domains(&["https://api.external.com"]),
463 "upstream.internal",
464 "https://proxy.example.com",
465 );
466
467 assert_eq!(out, domains(&["https://proxy.example.com"]));
468 }
469
470 #[test]
473 fn effective__frame_directive_default_is_empty_not_proxy() {
474 let cfg = CspConfig::default();
479 let out = effective_domains(
480 &cfg,
481 Directive::Frame,
482 None,
483 &domains(&["https://embed.external.com"]),
484 "upstream.internal",
485 "https://proxy.example.com",
486 );
487
488 assert!(out.is_empty(), "expected empty, got {out:?}");
489 }
490
491 #[test]
492 fn effective__frame_directive_with_declared_domains_omits_proxy() {
493 let cfg = CspConfig {
496 frame_domains: policy(&["https://embed.partner.com"], Mode::Extend),
497 ..CspConfig::default()
498 };
499 let out = effective_domains(
500 &cfg,
501 Directive::Frame,
502 None,
503 &[],
504 "upstream.internal",
505 "https://proxy.example.com",
506 );
507
508 assert_eq!(out, domains(&["https://embed.partner.com"]));
509 }
510
511 #[test]
512 fn effective__connect_and_resource_still_get_proxy_prepend() {
513 let cfg = CspConfig::default();
515 for directive in [Directive::Connect, Directive::Resource] {
516 let out = effective_domains(
517 &cfg,
518 directive,
519 None,
520 &[],
521 "upstream.internal",
522 "https://proxy.example.com",
523 );
524 assert_eq!(
525 out,
526 domains(&["https://proxy.example.com"]),
527 "directive {directive:?}"
528 );
529 }
530 }
531
532 #[test]
535 fn effective__widget_extend_adds_on_top_of_global() {
536 let cfg = CspConfig {
537 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
538 widgets: vec![widget(
539 "ui://widget/payment*",
540 &["https://api.stripe.com"],
541 Mode::Extend,
542 )],
543 ..CspConfig::default()
544 };
545
546 let out = effective_domains(
547 &cfg,
548 Directive::Connect,
549 Some("ui://widget/payment-form"),
550 &[],
551 "upstream.internal",
552 "https://proxy.example.com",
553 );
554
555 assert_eq!(
556 out,
557 domains(&[
558 "https://proxy.example.com",
559 "https://api.mine.com",
560 "https://api.stripe.com",
561 ])
562 );
563 }
564
565 #[test]
566 fn effective__widget_with_no_matching_uri_is_ignored() {
567 let cfg = CspConfig {
568 widgets: vec![widget(
569 "ui://widget/payment*",
570 &["https://api.stripe.com"],
571 Mode::Extend,
572 )],
573 ..CspConfig::default()
574 };
575
576 let out = effective_domains(
577 &cfg,
578 Directive::Connect,
579 Some("ui://widget/search"),
580 &[],
581 "upstream.internal",
582 "https://proxy.example.com",
583 );
584
585 assert_eq!(out, domains(&["https://proxy.example.com"]));
586 }
587
588 #[test]
589 fn effective__widget_without_uri_context_falls_back_to_global() {
590 let cfg = CspConfig {
591 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
592 widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
593 ..CspConfig::default()
594 };
595
596 let out = effective_domains(
597 &cfg,
598 Directive::Connect,
599 None,
600 &[],
601 "upstream.internal",
602 "https://proxy.example.com",
603 );
604
605 assert_eq!(
606 out,
607 domains(&["https://proxy.example.com", "https://api.mine.com"])
608 );
609 }
610
611 #[test]
614 fn effective__widget_replace_wipes_everything_before_it() {
615 let cfg = CspConfig {
616 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
617 widgets: vec![widget(
618 "ui://widget/payment*",
619 &["https://api.stripe.com"],
620 Mode::Replace,
621 )],
622 ..CspConfig::default()
623 };
624
625 let out = effective_domains(
626 &cfg,
627 Directive::Connect,
628 Some("ui://widget/payment-form"),
629 &domains(&["https://api.external.com"]),
630 "upstream.internal",
631 "https://proxy.example.com",
632 );
633
634 assert_eq!(
635 out,
636 domains(&["https://proxy.example.com", "https://api.stripe.com"])
637 );
638 }
639
640 #[test]
641 fn effective__widget_replace_with_empty_domains_clears_list() {
642 let cfg = CspConfig {
643 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
644 widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
645 ..CspConfig::default()
646 };
647
648 let out = effective_domains(
649 &cfg,
650 Directive::Connect,
651 Some("ui://widget/anything"),
652 &domains(&["https://api.external.com"]),
653 "upstream.internal",
654 "https://proxy.example.com",
655 );
656
657 assert_eq!(out, domains(&["https://proxy.example.com"]));
658 }
659
660 #[test]
661 fn effective__widget_extend_with_empty_domains_is_noop() {
662 let cfg = CspConfig {
666 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
667 widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
668 ..CspConfig::default()
669 };
670
671 let out = effective_domains(
672 &cfg,
673 Directive::Connect,
674 Some("ui://widget/anything"),
675 &[],
676 "upstream.internal",
677 "https://proxy.example.com",
678 );
679
680 assert_eq!(
681 out,
682 domains(&["https://proxy.example.com", "https://api.mine.com"])
683 );
684 }
685
686 #[test]
689 fn effective__multiple_matching_widgets_apply_in_config_order() {
690 let cfg = CspConfig {
691 widgets: vec![
692 widget("ui://widget/*", &["https://a.com"], Mode::Extend),
693 widget("ui://widget/*", &["https://b.com"], Mode::Replace),
694 widget("ui://widget/*", &["https://c.com"], Mode::Extend),
695 ],
696 ..CspConfig::default()
697 };
698
699 let out = effective_domains(
700 &cfg,
701 Directive::Connect,
702 Some("ui://widget/anything"),
703 &[],
704 "upstream.internal",
705 "https://proxy.example.com",
706 );
707
708 assert_eq!(
711 out,
712 domains(&[
713 "https://proxy.example.com",
714 "https://b.com",
715 "https://c.com"
716 ])
717 );
718 }
719
720 #[test]
723 fn effective__dedupes_across_sources() {
724 let cfg = CspConfig {
725 connect_domains: policy(&["https://shared.com"], Mode::Extend),
726 widgets: vec![widget(
727 "ui://widget/*",
728 &["https://shared.com"],
729 Mode::Extend,
730 )],
731 ..CspConfig::default()
732 };
733
734 let out = effective_domains(
735 &cfg,
736 Directive::Connect,
737 Some("ui://widget/x"),
738 &domains(&["https://shared.com"]),
739 "upstream.internal",
740 "https://proxy.example.com",
741 );
742
743 assert_eq!(
744 out,
745 domains(&["https://proxy.example.com", "https://shared.com"])
746 );
747 }
748
749 #[test]
750 fn effective__dedupes_proxy_url_already_in_upstream() {
751 let cfg = CspConfig::default();
752 let out = effective_domains(
753 &cfg,
754 Directive::Connect,
755 None,
756 &domains(&["https://proxy.example.com", "https://api.external.com"]),
757 "upstream.internal",
758 "https://proxy.example.com",
759 );
760 let count = out
761 .iter()
762 .filter(|d| *d == "https://proxy.example.com")
763 .count();
764 assert_eq!(count, 1);
765 }
766
767 #[test]
770 fn effective__strips_localhost() {
771 let cfg = CspConfig::default();
772 let out = effective_domains(
773 &cfg,
774 Directive::Connect,
775 None,
776 &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
777 "upstream.internal",
778 "https://proxy.example.com",
779 );
780 assert_eq!(out, domains(&["https://proxy.example.com"]));
781 }
782
783 #[test]
784 fn effective__strips_upstream_host() {
785 let cfg = CspConfig::default();
786 let out = effective_domains(
787 &cfg,
788 Directive::Connect,
789 None,
790 &domains(&["https://upstream.internal", "https://api.external.com"]),
791 "upstream.internal",
792 "https://proxy.example.com",
793 );
794 assert_eq!(
795 out,
796 domains(&["https://proxy.example.com", "https://api.external.com"])
797 );
798 }
799
800 #[test]
801 fn effective__empty_upstream_host_disables_self_stripping() {
802 let cfg = CspConfig::default();
805 let out = effective_domains(
806 &cfg,
807 Directive::Connect,
808 None,
809 &domains(&["https://api.external.com"]),
810 "",
811 "https://proxy.example.com",
812 );
813 assert_eq!(
814 out,
815 domains(&["https://proxy.example.com", "https://api.external.com"])
816 );
817 }
818
819 #[test]
822 fn glob__literal_match() {
823 assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
824 }
825
826 #[test]
827 fn glob__literal_mismatch() {
828 assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
829 }
830
831 #[test]
832 fn glob__star_matches_suffix() {
833 assert!(glob_match(
834 "ui://widget/payment*",
835 "ui://widget/payment-form"
836 ));
837 assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
838 }
839
840 #[test]
841 fn glob__star_matches_any_sequence() {
842 assert!(glob_match("ui://*/payment", "ui://widget/payment"));
843 assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
844 }
845
846 #[test]
847 fn glob__double_star_segment() {
848 assert!(glob_match("ui://widget/*", "ui://widget/anything"));
849 }
850
851 #[test]
852 fn glob__question_matches_single_char() {
853 assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
854 assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
855 }
856
857 #[test]
858 fn glob__empty_pattern_matches_empty_string_only() {
859 assert!(glob_match("", ""));
860 assert!(!glob_match("", "anything"));
861 }
862
863 #[test]
864 fn glob__star_only_matches_anything() {
865 assert!(glob_match("*", ""));
866 assert!(glob_match("*", "anything"));
867 }
868
869 #[test]
872 fn mode__display() {
873 assert_eq!(Mode::Extend.to_string(), "extend");
874 assert_eq!(Mode::Replace.to_string(), "replace");
875 }
876
877 #[test]
880 fn public_origin__accepts_https_host() {
881 assert!(is_public_proxy_origin("https://widgets.example.com"));
882 }
883
884 #[test]
885 fn public_origin__rejects_empty() {
886 assert!(!is_public_proxy_origin(""));
887 }
888
889 #[test]
890 fn public_origin__rejects_localhost() {
891 assert!(!is_public_proxy_origin("http://localhost:9002"));
892 }
893
894 #[test]
895 fn public_origin__rejects_loopback_ip() {
896 assert!(!is_public_proxy_origin("http://127.0.0.1:9002"));
897 }
898
899 #[test]
902 fn effective__skips_prepend_when_proxy_is_localhost() {
903 let cfg = CspConfig {
907 resource_domains: DirectivePolicy {
908 domains: domains(&["https://cdn.example.com"]),
909 mode: Mode::Extend,
910 },
911 ..CspConfig::default()
912 };
913 let out = effective_domains(
914 &cfg,
915 Directive::Resource,
916 None,
917 &[],
918 "",
919 "http://localhost:9002",
920 );
921 assert_eq!(out, domains(&["https://cdn.example.com"]));
922 }
923
924 #[test]
925 fn effective__skips_prepend_when_proxy_url_empty() {
926 let cfg = CspConfig {
927 connect_domains: DirectivePolicy {
928 domains: domains(&["https://api.example.com"]),
929 mode: Mode::Extend,
930 },
931 ..CspConfig::default()
932 };
933 let out = effective_domains(&cfg, Directive::Connect, None, &[], "", "");
934 assert_eq!(out, domains(&["https://api.example.com"]));
935 }
936}