Skip to main content

mcpr_core/proxy/
csp.rs

1//! # CSP — Declarative Content Security Policy for widgets
2//!
3//! This module owns the config types and the merge function that together decide
4//! which domains appear in a widget's CSP when mcpr rewrites an MCP response.
5//!
6//! ## Model
7//!
8//! A widget's CSP has three independent directive arrays:
9//!
10//! - `connectDomains` — allowed targets for `fetch`, `WebSocket`, `EventSource`.
11//! - `resourceDomains` — allowed sources for scripts, styles, images, fonts, media.
12//! - `frameDomains` — allowed sources for nested `<iframe>` content.
13//!
14//! Each directive carries its own [`DirectivePolicy`] — a list of domains and a
15//! [`Mode`] (`extend` or `replace`) that decides how to combine declared domains
16//! with whatever the upstream MCP server already returned.
17//!
18//! A top-level [`CspConfig`] holds one policy per directive plus an optional list
19//! of [`WidgetScoped`] entries. Widget entries match resource URIs with glob
20//! patterns (e.g. `ui://widget/payment*`) and layer on top of the global policy.
21//!
22//! ## Merge
23//!
24//! [`effective_domains`] computes the final domain list for one directive, given
25//! upstream domains, a resource URI, and the config. The rules are:
26//!
27//! 1. If the global directive's mode is `replace`, discard upstream entirely;
28//!    otherwise start from upstream minus localhost and the upstream host itself.
29//! 2. Append the global directive's declared domains.
30//! 3. For each widget entry whose `match` glob matches the resource URI, in
31//!    config order, either extend (append) or replace (overwrite) the working
32//!    list with the widget's domains for this directive.
33//! 4. For `connect` and `resource`, prepend the proxy URL and dedupe. `frame`
34//!    does not receive the proxy URL — widgets don't iframe the proxy back into
35//!    themselves, and prepending it would make every widget look like an
36//!    iframe-embedder to hosts that flag that shape for extra review.
37//!
38//! Replace semantics are scoped: a global replace only ignores upstream; a
39//! widget replace wipes everything accumulated above it.
40//!
41//! ## Example
42//!
43//! ```toml
44//! [csp.connectDomains]
45//! domains = ["api.example.com"]
46//! mode    = "extend"
47//!
48//! [csp.resourceDomains]
49//! domains = ["cdn.example.com"]
50//! mode    = "extend"
51//!
52//! [csp.frameDomains]
53//! domains = []
54//! mode    = "replace"
55//!
56//! [[csp.widget]]
57//! match              = "ui://widget/payment*"
58//! connectDomains     = ["api.stripe.com"]
59//! connectDomainsMode = "extend"
60//! ```
61
62use serde::Deserialize;
63
64/// Merge mode for a single CSP directive.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Mode {
68    /// Combine the directive's declared domains with upstream.
69    #[default]
70    Extend,
71    /// Ignore upstream for this directive; use only declared domains.
72    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/// Which of the three CSP directive arrays a policy targets.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Directive {
87    Connect,
88    Resource,
89    Frame,
90}
91
92/// A domain list paired with a merge mode.
93///
94/// Empty `domains` combined with `Mode::Extend` is a no-op. Empty `domains`
95/// combined with `Mode::Replace` explicitly clears the accumulated list.
96#[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    /// Build a policy that clears the accumulated list — the default for
105    /// `frameDomains`, which fails closed unless the operator opts in.
106    pub fn strict() -> Self {
107        Self {
108            domains: Vec::new(),
109            mode: Mode::Replace,
110        }
111    }
112}
113
114/// Per-widget override matched by glob on resource URI.
115///
116/// Directives are addressed as two paired fields: a domains list and a mode.
117/// Omitting both pairs for a directive leaves that directive untouched by the
118/// widget. Setting `mode = "replace"` with an empty domains list clears the
119/// accumulated domains for that directive.
120#[derive(Clone, Debug, Default, Deserialize)]
121#[serde(default)]
122pub struct WidgetScoped {
123    /// Glob pattern matched against a resource URI. `*` matches any sequence,
124    /// `?` matches one character. Literal everywhere else.
125    #[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    /// Fetch the (domains, mode) pair for one directive.
146    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/// Complete CSP configuration: three global directives plus widget overrides.
156#[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    /// Bare public host (no scheme) declared by the operator — feeds
163    /// `openai/widgetDomain` and the proxy-URL injection into widget CSP.
164    /// When `None`, the runtime falls back to the tunnel URL, and when no
165    /// public origin is available at all (local-only dev) the injection
166    /// is suppressed rather than polluting widget config with `localhost`.
167    pub public_widget_domain: Option<String>,
168}
169
170impl Default for CspConfig {
171    /// Defaults to permissive extend for connect and resource, and strict
172    /// replace for frame. Frames default strict because nested iframes are
173    /// rare in MCP widgets and the blast radius of an accidental allow is high.
174    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
185/// True when `url` is a non-empty origin worth injecting into widget CSP.
186///
187/// Local addresses (`localhost`, `127.0.0.1`) and empty strings are treated
188/// as "no public origin" — widgets submitted to ChatGPT or Claude can't reach
189/// them, and leaving localhost in the emitted CSP just ships garbage.
190pub(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
204/// Compute the effective domain list for one directive.
205///
206/// - `upstream_domains` are the values the MCP server declared for this
207///   directive; they pass through only when the global mode is `Extend`.
208/// - `resource_uri` selects which `[[csp.widget]]` overrides apply.
209/// - `upstream_host` is the bare host (no scheme) used to strip upstream
210///   self-references that would leak localhost into the proxied CSP.
211/// - `proxy_url` is prepended for `connect` and `resource` so widgets can
212///   reach the proxy for API calls and asset loads. It is NOT prepended for
213///   `frame` — widgets don't iframe the proxy back into themselves, and
214///   including it there makes every widget look like an iframe-embedder.
215pub 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    // 1. Seed from upstream unless the global mode replaces it.
226    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    // 2. Always add the globally declared domains.
237    for d in &global.domains {
238        push_unique(&mut base, d);
239    }
240
241    // 3. Walk widget overrides in config order. A widget with empty domains
242    //    and extend mode is a no-op; we skip it to keep the diff clean.
243    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    // 4. Prepend the proxy URL for directives that need to reach the proxy
263    //    itself (API calls, asset loads). Frame is intentionally skipped —
264    //    see module docs. The prepend is also suppressed when `proxy_url`
265    //    is empty or loopback: widgets shipped to ChatGPT/Claude can't reach
266    //    a local dev address, so leaving it in the CSP just pollutes the
267    //    submitted template. Dedupe preserving first-seen order.
268    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
285/// Returns true if `domain` points at the upstream MCP server itself or at
286/// a local loopback address. These values only make sense in dev; the proxy
287/// replaces them with its own URL so the widget reaches the proxy instead.
288fn 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
295/// Minimal glob matcher over bytes. Supports `*` (any sequence) and `?`
296/// (single character). Everything else matches literally.
297pub 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        // `*` matches zero or more characters; try consuming none first, then
307        // consume one from the input and retry. Patterns are short (tens of
308        // bytes) so the recursion depth stays bounded.
309        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    // ── helpers ────────────────────────────────────────────────────────────
329
330    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    // ── Mode serde ─────────────────────────────────────────────────────────
351
352    #[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    // ── CspConfig defaults ─────────────────────────────────────────────────
370
371    #[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    // ── effective_domains: global extend ───────────────────────────────────
380
381    #[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    // ── effective_domains: global replace ──────────────────────────────────
428
429    #[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    // ── effective_domains: frame does not get the proxy URL ───────────────
471
472    #[test]
473    fn effective__frame_directive_default_is_empty_not_proxy() {
474        // Frame defaults to strict replace with empty domains. Unlike connect
475        // and resource, we must NOT prepend the proxy URL — widgets don't
476        // iframe the proxy back into themselves, and including it flags the
477        // widget as an iframe-embedder to hosts like ChatGPT.
478        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        // When the operator declares frame domains, they pass through verbatim
494        // — still no proxy URL prefix.
495        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        // Regression guard: the frame-skip must not affect connect/resource.
514        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    // ── effective_domains: widget extend ───────────────────────────────────
533
534    #[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    // ── effective_domains: widget replace ──────────────────────────────────
612
613    #[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        // An empty + extend widget entry must not change anything, even when
663        // it matches the URI. Operators use this shape when they set modes
664        // for some directives but not others on the same widget block.
665        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    // ── effective_domains: widget ordering ────────────────────────────────
687
688    #[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        // First widget extends with a.com, second wipes and sets b.com,
709        // third extends with c.com. Proxy URL is always prepended last.
710        assert_eq!(
711            out,
712            domains(&[
713                "https://proxy.example.com",
714                "https://b.com",
715                "https://c.com"
716            ])
717        );
718    }
719
720    // ── effective_domains: dedupe ──────────────────────────────────────────
721
722    #[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    // ── self-reference stripping ───────────────────────────────────────────
768
769    #[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        // When the upstream host is unknown (empty), only localhost heuristics
803        // remain. External domains pass through.
804        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    // ── glob_match ─────────────────────────────────────────────────────────
820
821    #[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    // ── Mode Display ───────────────────────────────────────────────────────
870
871    #[test]
872    fn mode__display() {
873        assert_eq!(Mode::Extend.to_string(), "extend");
874        assert_eq!(Mode::Replace.to_string(), "replace");
875    }
876
877    // ── is_public_proxy_origin ─────────────────────────────────────────────
878
879    #[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    // ── effective_domains: localhost suppression ───────────────────────────
900
901    #[test]
902    fn effective__skips_prepend_when_proxy_is_localhost() {
903        // Local-only dev: we declared resource domains but no public origin
904        // is available. The emitted list must NOT contain the loopback URL —
905        // a submitted template should never ship `localhost` in its CSP.
906        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}