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}
163
164impl Default for CspConfig {
165 fn default() -> Self {
169 Self {
170 connect_domains: DirectivePolicy::default(),
171 resource_domains: DirectivePolicy::default(),
172 frame_domains: DirectivePolicy::strict(),
173 widgets: Vec::new(),
174 }
175 }
176}
177
178impl CspConfig {
179 fn policy(&self, d: Directive) -> &DirectivePolicy {
180 match d {
181 Directive::Connect => &self.connect_domains,
182 Directive::Resource => &self.resource_domains,
183 Directive::Frame => &self.frame_domains,
184 }
185 }
186}
187
188pub fn effective_domains(
200 cfg: &CspConfig,
201 directive: Directive,
202 resource_uri: Option<&str>,
203 upstream_domains: &[String],
204 upstream_host: &str,
205 proxy_url: &str,
206) -> Vec<String> {
207 let global = cfg.policy(directive);
208
209 let mut base: Vec<String> = if global.mode == Mode::Replace {
211 Vec::new()
212 } else {
213 upstream_domains
214 .iter()
215 .filter(|d| !is_self_reference(d, upstream_host))
216 .cloned()
217 .collect()
218 };
219
220 for d in &global.domains {
222 push_unique(&mut base, d);
223 }
224
225 if let Some(uri) = resource_uri {
228 for w in &cfg.widgets {
229 if !glob_match(&w.match_pattern, uri) {
230 continue;
231 }
232 let (domains, mode) = w.for_directive(directive);
233 if domains.is_empty() && mode == Mode::Extend {
234 continue;
235 }
236 if mode == Mode::Replace {
237 base = domains.to_vec();
238 } else {
239 for d in domains {
240 push_unique(&mut base, d);
241 }
242 }
243 }
244 }
245
246 let mut out = if directive == Directive::Frame {
250 Vec::new()
251 } else {
252 vec![proxy_url.to_string()]
253 };
254 for d in base {
255 push_unique(&mut out, &d);
256 }
257 out
258}
259
260fn push_unique(list: &mut Vec<String>, value: &str) {
261 if !list.iter().any(|s| s == value) {
262 list.push(value.to_string());
263 }
264}
265
266fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
270 if domain.contains("localhost") || domain.contains("127.0.0.1") {
271 return true;
272 }
273 !upstream_host.is_empty() && domain.contains(upstream_host)
274}
275
276pub fn glob_match(pattern: &str, input: &str) -> bool {
279 glob_rec(pattern.as_bytes(), input.as_bytes())
280}
281
282fn glob_rec(p: &[u8], t: &[u8]) -> bool {
283 if p.is_empty() {
284 return t.is_empty();
285 }
286 if p[0] == b'*' {
287 if glob_rec(&p[1..], t) {
291 return true;
292 }
293 if !t.is_empty() {
294 return glob_rec(p, &t[1..]);
295 }
296 return false;
297 }
298 if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
299 return glob_rec(&p[1..], &t[1..]);
300 }
301 false
302}
303
304#[cfg(test)]
305#[allow(non_snake_case)]
306mod tests {
307 use super::*;
308
309 fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
312 DirectivePolicy {
313 domains: domains.iter().map(|s| s.to_string()).collect(),
314 mode,
315 }
316 }
317
318 fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
319 WidgetScoped {
320 match_pattern: pattern.to_string(),
321 connect_domains: connect.iter().map(|s| s.to_string()).collect(),
322 connect_domains_mode: mode,
323 ..Default::default()
324 }
325 }
326
327 fn domains(items: &[&str]) -> Vec<String> {
328 items.iter().map(|s| s.to_string()).collect()
329 }
330
331 #[test]
334 fn mode__deserialises_extend() {
335 let m: Mode = serde_json::from_str("\"extend\"").unwrap();
336 assert_eq!(m, Mode::Extend);
337 }
338
339 #[test]
340 fn mode__deserialises_replace() {
341 let m: Mode = serde_json::from_str("\"replace\"").unwrap();
342 assert_eq!(m, Mode::Replace);
343 }
344
345 #[test]
346 fn mode__default_is_extend() {
347 assert_eq!(Mode::default(), Mode::Extend);
348 }
349
350 #[test]
353 fn csp_config__default_strict_frames() {
354 let c = CspConfig::default();
355 assert_eq!(c.connect_domains.mode, Mode::Extend);
356 assert_eq!(c.resource_domains.mode, Mode::Extend);
357 assert_eq!(c.frame_domains.mode, Mode::Replace);
358 }
359
360 #[test]
363 fn effective__extend_keeps_external_drops_upstream_host() {
364 let cfg = CspConfig::default();
365 let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
366
367 let out = effective_domains(
368 &cfg,
369 Directive::Connect,
370 None,
371 &upstream,
372 "localhost:9000",
373 "https://proxy.example.com",
374 );
375
376 assert_eq!(
377 out,
378 domains(&["https://proxy.example.com", "https://api.external.com"])
379 );
380 }
381
382 #[test]
383 fn effective__extend_adds_global_domains() {
384 let cfg = CspConfig {
385 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
386 ..CspConfig::default()
387 };
388
389 let out = effective_domains(
390 &cfg,
391 Directive::Connect,
392 None,
393 &domains(&["https://api.external.com"]),
394 "upstream.internal",
395 "https://proxy.example.com",
396 );
397
398 assert_eq!(
399 out,
400 domains(&[
401 "https://proxy.example.com",
402 "https://api.external.com",
403 "https://api.mine.com",
404 ])
405 );
406 }
407
408 #[test]
411 fn effective__replace_ignores_upstream() {
412 let cfg = CspConfig {
413 connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
414 ..CspConfig::default()
415 };
416
417 let out = effective_domains(
418 &cfg,
419 Directive::Connect,
420 None,
421 &domains(&["https://api.external.com"]),
422 "upstream.internal",
423 "https://proxy.example.com",
424 );
425
426 assert_eq!(
427 out,
428 domains(&["https://proxy.example.com", "https://api.mine.com"])
429 );
430 }
431
432 #[test]
433 fn effective__replace_with_empty_global_leaves_only_proxy() {
434 let cfg = CspConfig {
435 connect_domains: policy(&[], 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!(out, domains(&["https://proxy.example.com"]));
449 }
450
451 #[test]
454 fn effective__frame_directive_default_is_empty_not_proxy() {
455 let cfg = CspConfig::default();
460 let out = effective_domains(
461 &cfg,
462 Directive::Frame,
463 None,
464 &domains(&["https://embed.external.com"]),
465 "upstream.internal",
466 "https://proxy.example.com",
467 );
468
469 assert!(out.is_empty(), "expected empty, got {out:?}");
470 }
471
472 #[test]
473 fn effective__frame_directive_with_declared_domains_omits_proxy() {
474 let cfg = CspConfig {
477 frame_domains: policy(&["https://embed.partner.com"], Mode::Extend),
478 ..CspConfig::default()
479 };
480 let out = effective_domains(
481 &cfg,
482 Directive::Frame,
483 None,
484 &[],
485 "upstream.internal",
486 "https://proxy.example.com",
487 );
488
489 assert_eq!(out, domains(&["https://embed.partner.com"]));
490 }
491
492 #[test]
493 fn effective__connect_and_resource_still_get_proxy_prepend() {
494 let cfg = CspConfig::default();
496 for directive in [Directive::Connect, Directive::Resource] {
497 let out = effective_domains(
498 &cfg,
499 directive,
500 None,
501 &[],
502 "upstream.internal",
503 "https://proxy.example.com",
504 );
505 assert_eq!(
506 out,
507 domains(&["https://proxy.example.com"]),
508 "directive {directive:?}"
509 );
510 }
511 }
512
513 #[test]
516 fn effective__widget_extend_adds_on_top_of_global() {
517 let cfg = CspConfig {
518 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
519 widgets: vec![widget(
520 "ui://widget/payment*",
521 &["https://api.stripe.com"],
522 Mode::Extend,
523 )],
524 ..CspConfig::default()
525 };
526
527 let out = effective_domains(
528 &cfg,
529 Directive::Connect,
530 Some("ui://widget/payment-form"),
531 &[],
532 "upstream.internal",
533 "https://proxy.example.com",
534 );
535
536 assert_eq!(
537 out,
538 domains(&[
539 "https://proxy.example.com",
540 "https://api.mine.com",
541 "https://api.stripe.com",
542 ])
543 );
544 }
545
546 #[test]
547 fn effective__widget_with_no_matching_uri_is_ignored() {
548 let cfg = CspConfig {
549 widgets: vec![widget(
550 "ui://widget/payment*",
551 &["https://api.stripe.com"],
552 Mode::Extend,
553 )],
554 ..CspConfig::default()
555 };
556
557 let out = effective_domains(
558 &cfg,
559 Directive::Connect,
560 Some("ui://widget/search"),
561 &[],
562 "upstream.internal",
563 "https://proxy.example.com",
564 );
565
566 assert_eq!(out, domains(&["https://proxy.example.com"]));
567 }
568
569 #[test]
570 fn effective__widget_without_uri_context_falls_back_to_global() {
571 let cfg = CspConfig {
572 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
573 widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
574 ..CspConfig::default()
575 };
576
577 let out = effective_domains(
578 &cfg,
579 Directive::Connect,
580 None,
581 &[],
582 "upstream.internal",
583 "https://proxy.example.com",
584 );
585
586 assert_eq!(
587 out,
588 domains(&["https://proxy.example.com", "https://api.mine.com"])
589 );
590 }
591
592 #[test]
595 fn effective__widget_replace_wipes_everything_before_it() {
596 let cfg = CspConfig {
597 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
598 widgets: vec![widget(
599 "ui://widget/payment*",
600 &["https://api.stripe.com"],
601 Mode::Replace,
602 )],
603 ..CspConfig::default()
604 };
605
606 let out = effective_domains(
607 &cfg,
608 Directive::Connect,
609 Some("ui://widget/payment-form"),
610 &domains(&["https://api.external.com"]),
611 "upstream.internal",
612 "https://proxy.example.com",
613 );
614
615 assert_eq!(
616 out,
617 domains(&["https://proxy.example.com", "https://api.stripe.com"])
618 );
619 }
620
621 #[test]
622 fn effective__widget_replace_with_empty_domains_clears_list() {
623 let cfg = CspConfig {
624 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
625 widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
626 ..CspConfig::default()
627 };
628
629 let out = effective_domains(
630 &cfg,
631 Directive::Connect,
632 Some("ui://widget/anything"),
633 &domains(&["https://api.external.com"]),
634 "upstream.internal",
635 "https://proxy.example.com",
636 );
637
638 assert_eq!(out, domains(&["https://proxy.example.com"]));
639 }
640
641 #[test]
642 fn effective__widget_extend_with_empty_domains_is_noop() {
643 let cfg = CspConfig {
647 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
648 widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
649 ..CspConfig::default()
650 };
651
652 let out = effective_domains(
653 &cfg,
654 Directive::Connect,
655 Some("ui://widget/anything"),
656 &[],
657 "upstream.internal",
658 "https://proxy.example.com",
659 );
660
661 assert_eq!(
662 out,
663 domains(&["https://proxy.example.com", "https://api.mine.com"])
664 );
665 }
666
667 #[test]
670 fn effective__multiple_matching_widgets_apply_in_config_order() {
671 let cfg = CspConfig {
672 widgets: vec![
673 widget("ui://widget/*", &["https://a.com"], Mode::Extend),
674 widget("ui://widget/*", &["https://b.com"], Mode::Replace),
675 widget("ui://widget/*", &["https://c.com"], Mode::Extend),
676 ],
677 ..CspConfig::default()
678 };
679
680 let out = effective_domains(
681 &cfg,
682 Directive::Connect,
683 Some("ui://widget/anything"),
684 &[],
685 "upstream.internal",
686 "https://proxy.example.com",
687 );
688
689 assert_eq!(
692 out,
693 domains(&[
694 "https://proxy.example.com",
695 "https://b.com",
696 "https://c.com"
697 ])
698 );
699 }
700
701 #[test]
704 fn effective__dedupes_across_sources() {
705 let cfg = CspConfig {
706 connect_domains: policy(&["https://shared.com"], Mode::Extend),
707 widgets: vec![widget(
708 "ui://widget/*",
709 &["https://shared.com"],
710 Mode::Extend,
711 )],
712 ..CspConfig::default()
713 };
714
715 let out = effective_domains(
716 &cfg,
717 Directive::Connect,
718 Some("ui://widget/x"),
719 &domains(&["https://shared.com"]),
720 "upstream.internal",
721 "https://proxy.example.com",
722 );
723
724 assert_eq!(
725 out,
726 domains(&["https://proxy.example.com", "https://shared.com"])
727 );
728 }
729
730 #[test]
731 fn effective__dedupes_proxy_url_already_in_upstream() {
732 let cfg = CspConfig::default();
733 let out = effective_domains(
734 &cfg,
735 Directive::Connect,
736 None,
737 &domains(&["https://proxy.example.com", "https://api.external.com"]),
738 "upstream.internal",
739 "https://proxy.example.com",
740 );
741 let count = out
742 .iter()
743 .filter(|d| *d == "https://proxy.example.com")
744 .count();
745 assert_eq!(count, 1);
746 }
747
748 #[test]
751 fn effective__strips_localhost() {
752 let cfg = CspConfig::default();
753 let out = effective_domains(
754 &cfg,
755 Directive::Connect,
756 None,
757 &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
758 "upstream.internal",
759 "https://proxy.example.com",
760 );
761 assert_eq!(out, domains(&["https://proxy.example.com"]));
762 }
763
764 #[test]
765 fn effective__strips_upstream_host() {
766 let cfg = CspConfig::default();
767 let out = effective_domains(
768 &cfg,
769 Directive::Connect,
770 None,
771 &domains(&["https://upstream.internal", "https://api.external.com"]),
772 "upstream.internal",
773 "https://proxy.example.com",
774 );
775 assert_eq!(
776 out,
777 domains(&["https://proxy.example.com", "https://api.external.com"])
778 );
779 }
780
781 #[test]
782 fn effective__empty_upstream_host_disables_self_stripping() {
783 let cfg = CspConfig::default();
786 let out = effective_domains(
787 &cfg,
788 Directive::Connect,
789 None,
790 &domains(&["https://api.external.com"]),
791 "",
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]
803 fn glob__literal_match() {
804 assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
805 }
806
807 #[test]
808 fn glob__literal_mismatch() {
809 assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
810 }
811
812 #[test]
813 fn glob__star_matches_suffix() {
814 assert!(glob_match(
815 "ui://widget/payment*",
816 "ui://widget/payment-form"
817 ));
818 assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
819 }
820
821 #[test]
822 fn glob__star_matches_any_sequence() {
823 assert!(glob_match("ui://*/payment", "ui://widget/payment"));
824 assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
825 }
826
827 #[test]
828 fn glob__double_star_segment() {
829 assert!(glob_match("ui://widget/*", "ui://widget/anything"));
830 }
831
832 #[test]
833 fn glob__question_matches_single_char() {
834 assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
835 assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
836 }
837
838 #[test]
839 fn glob__empty_pattern_matches_empty_string_only() {
840 assert!(glob_match("", ""));
841 assert!(!glob_match("", "anything"));
842 }
843
844 #[test]
845 fn glob__star_only_matches_anything() {
846 assert!(glob_match("*", ""));
847 assert!(glob_match("*", "anything"));
848 }
849
850 #[test]
853 fn mode__display() {
854 assert_eq!(Mode::Extend.to_string(), "extend");
855 assert_eq!(Mode::Replace.to_string(), "replace");
856 }
857}