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