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