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 the
163    /// `openai/widgetDomain` meta field and the proxy-URL injection into
164    /// widget CSP. `_meta.ui.domain` is intentionally left untouched: Claude
165    /// derives that value from the proxy URL itself and rejects any value
166    /// supplied by an MCP layer. When `None`, the runtime falls back to the
167    /// tunnel URL, and when no public origin is available at all (local-only
168    /// dev) the injection is suppressed rather than polluting widget config
169    /// with `localhost`.
170    pub domain: Option<String>,
171}
172
173impl Default for CspConfig {
174    /// Defaults to permissive extend for connect and resource, and strict
175    /// replace for frame. Frames default strict because nested iframes are
176    /// rare in MCP widgets and the blast radius of an accidental allow is high.
177    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
188/// True when `url` is a non-empty origin worth injecting into widget CSP.
189///
190/// Local addresses (`localhost`, `127.0.0.1`) and empty strings are treated
191/// as "no public origin" — widgets submitted to ChatGPT or Claude can't reach
192/// them, and leaving localhost in the emitted CSP just ships garbage.
193pub(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
207/// Compute the effective domain list for one directive.
208///
209/// - `upstream_domains` are the values the MCP server declared for this
210///   directive; they pass through only when the global mode is `Extend`.
211/// - `resource_uri` selects which `[[csp.widget]]` overrides apply.
212/// - `upstream_host` is the bare host (no scheme) used to strip upstream
213///   self-references that would leak localhost into the proxied CSP.
214/// - `proxy_url` is prepended for `connect` and `resource` so widgets can
215///   reach the proxy for API calls and asset loads. It is NOT prepended for
216///   `frame` — widgets don't iframe the proxy back into themselves, and
217///   including it there makes every widget look like an iframe-embedder.
218pub 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    // 1. Seed from upstream unless the global mode replaces it.
229    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    // 2. Always add the globally declared domains.
240    for d in &global.domains {
241        push_unique(&mut base, d);
242    }
243
244    // 3. Walk widget overrides in config order. A widget with empty domains
245    //    and extend mode is a no-op; we skip it to keep the diff clean.
246    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    // 4. Prepend the proxy URL for directives that need to reach the proxy
266    //    itself (API calls, asset loads). Frame is intentionally skipped —
267    //    see module docs. The prepend is also suppressed when `proxy_url`
268    //    is empty or loopback: widgets shipped to ChatGPT/Claude can't reach
269    //    a local dev address, so leaving it in the CSP just pollutes the
270    //    submitted template. Dedupe preserving first-seen order.
271    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
288/// Returns true if `domain` points at the upstream MCP server itself or at
289/// a local loopback address. These values only make sense in dev; the proxy
290/// replaces them with its own URL so the widget reaches the proxy instead.
291fn 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
298/// Minimal glob matcher over bytes. Supports `*` (any sequence) and `?`
299/// (single character). Everything else matches literally.
300pub 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        // `*` matches zero or more characters; try consuming none first, then
310        // consume one from the input and retry. Patterns are short (tens of
311        // bytes) so the recursion depth stays bounded.
312        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    // ── helpers ────────────────────────────────────────────────────────────
332
333    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    // ── Mode serde ─────────────────────────────────────────────────────────
354
355    #[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    // ── CspConfig defaults ─────────────────────────────────────────────────
373
374    #[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    // ── effective_domains: global extend ───────────────────────────────────
383
384    #[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    // ── effective_domains: global replace ──────────────────────────────────
431
432    #[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    // ── effective_domains: frame does not get the proxy URL ───────────────
474
475    #[test]
476    fn effective__frame_directive_default_is_empty_not_proxy() {
477        // Frame defaults to strict replace with empty domains. Unlike connect
478        // and resource, we must NOT prepend the proxy URL — widgets don't
479        // iframe the proxy back into themselves, and including it flags the
480        // widget as an iframe-embedder to hosts like ChatGPT.
481        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        // When the operator declares frame domains, they pass through verbatim
497        // — still no proxy URL prefix.
498        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        // Regression guard: the frame-skip must not affect connect/resource.
517        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    // ── effective_domains: widget extend ───────────────────────────────────
536
537    #[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    // ── effective_domains: widget replace ──────────────────────────────────
615
616    #[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        // An empty + extend widget entry must not change anything, even when
666        // it matches the URI. Operators use this shape when they set modes
667        // for some directives but not others on the same widget block.
668        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    // ── effective_domains: widget ordering ────────────────────────────────
690
691    #[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        // First widget extends with a.com, second wipes and sets b.com,
712        // third extends with c.com. Proxy URL is always prepended last.
713        assert_eq!(
714            out,
715            domains(&[
716                "https://proxy.example.com",
717                "https://b.com",
718                "https://c.com"
719            ])
720        );
721    }
722
723    // ── effective_domains: dedupe ──────────────────────────────────────────
724
725    #[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    // ── self-reference stripping ───────────────────────────────────────────
771
772    #[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        // When the upstream host is unknown (empty), only localhost heuristics
806        // remain. External domains pass through.
807        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    // ── glob_match ─────────────────────────────────────────────────────────
823
824    #[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    // ── Mode Display ───────────────────────────────────────────────────────
873
874    #[test]
875    fn mode__display() {
876        assert_eq!(Mode::Extend.to_string(), "extend");
877        assert_eq!(Mode::Replace.to_string(), "replace");
878    }
879
880    // ── is_public_proxy_origin ─────────────────────────────────────────────
881
882    #[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    // ── effective_domains: localhost suppression ───────────────────────────
903
904    #[test]
905    fn effective__skips_prepend_when_proxy_is_localhost() {
906        // Local-only dev: we declared resource domains but no public origin
907        // is available. The emitted list must NOT contain the loopback URL —
908        // a submitted template should never ship `localhost` in its CSP.
909        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}