1use serde::Deserialize;
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Mode {
65 #[default]
67 Extend,
68 Replace,
70}
71
72impl std::fmt::Display for Mode {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Mode::Extend => write!(f, "extend"),
76 Mode::Replace => write!(f, "replace"),
77 }
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Directive {
84 Connect,
85 Resource,
86 Frame,
87}
88
89#[derive(Clone, Debug, Default, Deserialize)]
94#[serde(default)]
95pub struct DirectivePolicy {
96 pub domains: Vec<String>,
97 pub mode: Mode,
98}
99
100impl DirectivePolicy {
101 pub fn strict() -> Self {
104 Self {
105 domains: Vec::new(),
106 mode: Mode::Replace,
107 }
108 }
109}
110
111#[derive(Clone, Debug, Default, Deserialize)]
118#[serde(default)]
119pub struct WidgetScoped {
120 #[serde(rename = "match")]
123 pub match_pattern: String,
124
125 #[serde(rename = "connectDomains")]
126 pub connect_domains: Vec<String>,
127 #[serde(rename = "connectDomainsMode")]
128 pub connect_domains_mode: Mode,
129
130 #[serde(rename = "resourceDomains")]
131 pub resource_domains: Vec<String>,
132 #[serde(rename = "resourceDomainsMode")]
133 pub resource_domains_mode: Mode,
134
135 #[serde(rename = "frameDomains")]
136 pub frame_domains: Vec<String>,
137 #[serde(rename = "frameDomainsMode")]
138 pub frame_domains_mode: Mode,
139}
140
141impl WidgetScoped {
142 fn for_directive(&self, d: Directive) -> (&[String], Mode) {
144 match d {
145 Directive::Connect => (&self.connect_domains, self.connect_domains_mode),
146 Directive::Resource => (&self.resource_domains, self.resource_domains_mode),
147 Directive::Frame => (&self.frame_domains, self.frame_domains_mode),
148 }
149 }
150}
151
152#[derive(Clone, Debug)]
154pub struct CspConfig {
155 pub connect_domains: DirectivePolicy,
156 pub resource_domains: DirectivePolicy,
157 pub frame_domains: DirectivePolicy,
158 pub widgets: Vec<WidgetScoped>,
159}
160
161impl Default for CspConfig {
162 fn default() -> Self {
166 Self {
167 connect_domains: DirectivePolicy::default(),
168 resource_domains: DirectivePolicy::default(),
169 frame_domains: DirectivePolicy::strict(),
170 widgets: Vec::new(),
171 }
172 }
173}
174
175impl CspConfig {
176 fn policy(&self, d: Directive) -> &DirectivePolicy {
177 match d {
178 Directive::Connect => &self.connect_domains,
179 Directive::Resource => &self.resource_domains,
180 Directive::Frame => &self.frame_domains,
181 }
182 }
183}
184
185pub fn effective_domains(
194 cfg: &CspConfig,
195 directive: Directive,
196 resource_uri: Option<&str>,
197 upstream_domains: &[String],
198 upstream_host: &str,
199 proxy_url: &str,
200) -> Vec<String> {
201 let global = cfg.policy(directive);
202
203 let mut base: Vec<String> = if global.mode == Mode::Replace {
205 Vec::new()
206 } else {
207 upstream_domains
208 .iter()
209 .filter(|d| !is_self_reference(d, upstream_host))
210 .cloned()
211 .collect()
212 };
213
214 for d in &global.domains {
216 push_unique(&mut base, d);
217 }
218
219 if let Some(uri) = resource_uri {
222 for w in &cfg.widgets {
223 if !glob_match(&w.match_pattern, uri) {
224 continue;
225 }
226 let (domains, mode) = w.for_directive(directive);
227 if domains.is_empty() && mode == Mode::Extend {
228 continue;
229 }
230 if mode == Mode::Replace {
231 base = domains.to_vec();
232 } else {
233 for d in domains {
234 push_unique(&mut base, d);
235 }
236 }
237 }
238 }
239
240 let mut out = vec![proxy_url.to_string()];
243 for d in base {
244 push_unique(&mut out, &d);
245 }
246 out
247}
248
249fn push_unique(list: &mut Vec<String>, value: &str) {
250 if !list.iter().any(|s| s == value) {
251 list.push(value.to_string());
252 }
253}
254
255fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
259 if domain.contains("localhost") || domain.contains("127.0.0.1") {
260 return true;
261 }
262 !upstream_host.is_empty() && domain.contains(upstream_host)
263}
264
265pub fn glob_match(pattern: &str, input: &str) -> bool {
268 glob_rec(pattern.as_bytes(), input.as_bytes())
269}
270
271fn glob_rec(p: &[u8], t: &[u8]) -> bool {
272 if p.is_empty() {
273 return t.is_empty();
274 }
275 if p[0] == b'*' {
276 if glob_rec(&p[1..], t) {
280 return true;
281 }
282 if !t.is_empty() {
283 return glob_rec(p, &t[1..]);
284 }
285 return false;
286 }
287 if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
288 return glob_rec(&p[1..], &t[1..]);
289 }
290 false
291}
292
293#[cfg(test)]
294#[allow(non_snake_case)]
295mod tests {
296 use super::*;
297
298 fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
301 DirectivePolicy {
302 domains: domains.iter().map(|s| s.to_string()).collect(),
303 mode,
304 }
305 }
306
307 fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
308 WidgetScoped {
309 match_pattern: pattern.to_string(),
310 connect_domains: connect.iter().map(|s| s.to_string()).collect(),
311 connect_domains_mode: mode,
312 ..Default::default()
313 }
314 }
315
316 fn domains(items: &[&str]) -> Vec<String> {
317 items.iter().map(|s| s.to_string()).collect()
318 }
319
320 #[test]
323 fn mode__deserialises_extend() {
324 let m: Mode = serde_json::from_str("\"extend\"").unwrap();
325 assert_eq!(m, Mode::Extend);
326 }
327
328 #[test]
329 fn mode__deserialises_replace() {
330 let m: Mode = serde_json::from_str("\"replace\"").unwrap();
331 assert_eq!(m, Mode::Replace);
332 }
333
334 #[test]
335 fn mode__default_is_extend() {
336 assert_eq!(Mode::default(), Mode::Extend);
337 }
338
339 #[test]
342 fn csp_config__default_strict_frames() {
343 let c = CspConfig::default();
344 assert_eq!(c.connect_domains.mode, Mode::Extend);
345 assert_eq!(c.resource_domains.mode, Mode::Extend);
346 assert_eq!(c.frame_domains.mode, Mode::Replace);
347 }
348
349 #[test]
352 fn effective__extend_keeps_external_drops_upstream_host() {
353 let cfg = CspConfig::default();
354 let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
355
356 let out = effective_domains(
357 &cfg,
358 Directive::Connect,
359 None,
360 &upstream,
361 "localhost:9000",
362 "https://proxy.example.com",
363 );
364
365 assert_eq!(
366 out,
367 domains(&["https://proxy.example.com", "https://api.external.com"])
368 );
369 }
370
371 #[test]
372 fn effective__extend_adds_global_domains() {
373 let cfg = CspConfig {
374 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
375 ..CspConfig::default()
376 };
377
378 let out = effective_domains(
379 &cfg,
380 Directive::Connect,
381 None,
382 &domains(&["https://api.external.com"]),
383 "upstream.internal",
384 "https://proxy.example.com",
385 );
386
387 assert_eq!(
388 out,
389 domains(&[
390 "https://proxy.example.com",
391 "https://api.external.com",
392 "https://api.mine.com",
393 ])
394 );
395 }
396
397 #[test]
400 fn effective__replace_ignores_upstream() {
401 let cfg = CspConfig {
402 connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
403 ..CspConfig::default()
404 };
405
406 let out = effective_domains(
407 &cfg,
408 Directive::Connect,
409 None,
410 &domains(&["https://api.external.com"]),
411 "upstream.internal",
412 "https://proxy.example.com",
413 );
414
415 assert_eq!(
416 out,
417 domains(&["https://proxy.example.com", "https://api.mine.com"])
418 );
419 }
420
421 #[test]
422 fn effective__replace_with_empty_global_leaves_only_proxy() {
423 let cfg = CspConfig {
424 connect_domains: policy(&[], Mode::Replace),
425 ..CspConfig::default()
426 };
427
428 let out = effective_domains(
429 &cfg,
430 Directive::Connect,
431 None,
432 &domains(&["https://api.external.com"]),
433 "upstream.internal",
434 "https://proxy.example.com",
435 );
436
437 assert_eq!(out, domains(&["https://proxy.example.com"]));
438 }
439
440 #[test]
443 fn effective__widget_extend_adds_on_top_of_global() {
444 let cfg = CspConfig {
445 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
446 widgets: vec![widget(
447 "ui://widget/payment*",
448 &["https://api.stripe.com"],
449 Mode::Extend,
450 )],
451 ..CspConfig::default()
452 };
453
454 let out = effective_domains(
455 &cfg,
456 Directive::Connect,
457 Some("ui://widget/payment-form"),
458 &[],
459 "upstream.internal",
460 "https://proxy.example.com",
461 );
462
463 assert_eq!(
464 out,
465 domains(&[
466 "https://proxy.example.com",
467 "https://api.mine.com",
468 "https://api.stripe.com",
469 ])
470 );
471 }
472
473 #[test]
474 fn effective__widget_with_no_matching_uri_is_ignored() {
475 let cfg = CspConfig {
476 widgets: vec![widget(
477 "ui://widget/payment*",
478 &["https://api.stripe.com"],
479 Mode::Extend,
480 )],
481 ..CspConfig::default()
482 };
483
484 let out = effective_domains(
485 &cfg,
486 Directive::Connect,
487 Some("ui://widget/search"),
488 &[],
489 "upstream.internal",
490 "https://proxy.example.com",
491 );
492
493 assert_eq!(out, domains(&["https://proxy.example.com"]));
494 }
495
496 #[test]
497 fn effective__widget_without_uri_context_falls_back_to_global() {
498 let cfg = CspConfig {
499 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
500 widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
501 ..CspConfig::default()
502 };
503
504 let out = effective_domains(
505 &cfg,
506 Directive::Connect,
507 None,
508 &[],
509 "upstream.internal",
510 "https://proxy.example.com",
511 );
512
513 assert_eq!(
514 out,
515 domains(&["https://proxy.example.com", "https://api.mine.com"])
516 );
517 }
518
519 #[test]
522 fn effective__widget_replace_wipes_everything_before_it() {
523 let cfg = CspConfig {
524 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
525 widgets: vec![widget(
526 "ui://widget/payment*",
527 &["https://api.stripe.com"],
528 Mode::Replace,
529 )],
530 ..CspConfig::default()
531 };
532
533 let out = effective_domains(
534 &cfg,
535 Directive::Connect,
536 Some("ui://widget/payment-form"),
537 &domains(&["https://api.external.com"]),
538 "upstream.internal",
539 "https://proxy.example.com",
540 );
541
542 assert_eq!(
543 out,
544 domains(&["https://proxy.example.com", "https://api.stripe.com"])
545 );
546 }
547
548 #[test]
549 fn effective__widget_replace_with_empty_domains_clears_list() {
550 let cfg = CspConfig {
551 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
552 widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
553 ..CspConfig::default()
554 };
555
556 let out = effective_domains(
557 &cfg,
558 Directive::Connect,
559 Some("ui://widget/anything"),
560 &domains(&["https://api.external.com"]),
561 "upstream.internal",
562 "https://proxy.example.com",
563 );
564
565 assert_eq!(out, domains(&["https://proxy.example.com"]));
566 }
567
568 #[test]
569 fn effective__widget_extend_with_empty_domains_is_noop() {
570 let cfg = CspConfig {
574 connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
575 widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
576 ..CspConfig::default()
577 };
578
579 let out = effective_domains(
580 &cfg,
581 Directive::Connect,
582 Some("ui://widget/anything"),
583 &[],
584 "upstream.internal",
585 "https://proxy.example.com",
586 );
587
588 assert_eq!(
589 out,
590 domains(&["https://proxy.example.com", "https://api.mine.com"])
591 );
592 }
593
594 #[test]
597 fn effective__multiple_matching_widgets_apply_in_config_order() {
598 let cfg = CspConfig {
599 widgets: vec![
600 widget("ui://widget/*", &["https://a.com"], Mode::Extend),
601 widget("ui://widget/*", &["https://b.com"], Mode::Replace),
602 widget("ui://widget/*", &["https://c.com"], Mode::Extend),
603 ],
604 ..CspConfig::default()
605 };
606
607 let out = effective_domains(
608 &cfg,
609 Directive::Connect,
610 Some("ui://widget/anything"),
611 &[],
612 "upstream.internal",
613 "https://proxy.example.com",
614 );
615
616 assert_eq!(
619 out,
620 domains(&[
621 "https://proxy.example.com",
622 "https://b.com",
623 "https://c.com"
624 ])
625 );
626 }
627
628 #[test]
631 fn effective__dedupes_across_sources() {
632 let cfg = CspConfig {
633 connect_domains: policy(&["https://shared.com"], Mode::Extend),
634 widgets: vec![widget(
635 "ui://widget/*",
636 &["https://shared.com"],
637 Mode::Extend,
638 )],
639 ..CspConfig::default()
640 };
641
642 let out = effective_domains(
643 &cfg,
644 Directive::Connect,
645 Some("ui://widget/x"),
646 &domains(&["https://shared.com"]),
647 "upstream.internal",
648 "https://proxy.example.com",
649 );
650
651 assert_eq!(
652 out,
653 domains(&["https://proxy.example.com", "https://shared.com"])
654 );
655 }
656
657 #[test]
658 fn effective__dedupes_proxy_url_already_in_upstream() {
659 let cfg = CspConfig::default();
660 let out = effective_domains(
661 &cfg,
662 Directive::Connect,
663 None,
664 &domains(&["https://proxy.example.com", "https://api.external.com"]),
665 "upstream.internal",
666 "https://proxy.example.com",
667 );
668 let count = out
669 .iter()
670 .filter(|d| *d == "https://proxy.example.com")
671 .count();
672 assert_eq!(count, 1);
673 }
674
675 #[test]
678 fn effective__strips_localhost() {
679 let cfg = CspConfig::default();
680 let out = effective_domains(
681 &cfg,
682 Directive::Connect,
683 None,
684 &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
685 "upstream.internal",
686 "https://proxy.example.com",
687 );
688 assert_eq!(out, domains(&["https://proxy.example.com"]));
689 }
690
691 #[test]
692 fn effective__strips_upstream_host() {
693 let cfg = CspConfig::default();
694 let out = effective_domains(
695 &cfg,
696 Directive::Connect,
697 None,
698 &domains(&["https://upstream.internal", "https://api.external.com"]),
699 "upstream.internal",
700 "https://proxy.example.com",
701 );
702 assert_eq!(
703 out,
704 domains(&["https://proxy.example.com", "https://api.external.com"])
705 );
706 }
707
708 #[test]
709 fn effective__empty_upstream_host_disables_self_stripping() {
710 let cfg = CspConfig::default();
713 let out = effective_domains(
714 &cfg,
715 Directive::Connect,
716 None,
717 &domains(&["https://api.external.com"]),
718 "",
719 "https://proxy.example.com",
720 );
721 assert_eq!(
722 out,
723 domains(&["https://proxy.example.com", "https://api.external.com"])
724 );
725 }
726
727 #[test]
730 fn glob__literal_match() {
731 assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
732 }
733
734 #[test]
735 fn glob__literal_mismatch() {
736 assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
737 }
738
739 #[test]
740 fn glob__star_matches_suffix() {
741 assert!(glob_match(
742 "ui://widget/payment*",
743 "ui://widget/payment-form"
744 ));
745 assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
746 }
747
748 #[test]
749 fn glob__star_matches_any_sequence() {
750 assert!(glob_match("ui://*/payment", "ui://widget/payment"));
751 assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
752 }
753
754 #[test]
755 fn glob__double_star_segment() {
756 assert!(glob_match("ui://widget/*", "ui://widget/anything"));
757 }
758
759 #[test]
760 fn glob__question_matches_single_char() {
761 assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
762 assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
763 }
764
765 #[test]
766 fn glob__empty_pattern_matches_empty_string_only() {
767 assert!(glob_match("", ""));
768 assert!(!glob_match("", "anything"));
769 }
770
771 #[test]
772 fn glob__star_only_matches_anything() {
773 assert!(glob_match("*", ""));
774 assert!(glob_match("*", "anything"));
775 }
776
777 #[test]
780 fn mode__display() {
781 assert_eq!(Mode::Extend.to_string(), "extend");
782 assert_eq!(Mode::Replace.to_string(), "replace");
783 }
784}