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    /// widget-domain meta fields (`openai/widgetDomain` and `ui.domain`) and
164    /// the proxy-URL injection into widget CSP. When `None`, the runtime
165    /// falls back to the tunnel URL, and when no public origin is available
166    /// at all (local-only dev) the injection is suppressed rather than
167    /// polluting widget config with `localhost`.
168    pub domain: Option<String>,
169}
170
171impl Default for CspConfig {
172    /// Defaults to permissive extend for connect and resource, and strict
173    /// replace for frame. Frames default strict because nested iframes are
174    /// rare in MCP widgets and the blast radius of an accidental allow is high.
175    fn default() -> Self {
176        Self {
177            connect_domains: DirectivePolicy::default(),
178            resource_domains: DirectivePolicy::default(),
179            frame_domains: DirectivePolicy::strict(),
180            widgets: Vec::new(),
181            domain: None,
182        }
183    }
184}
185
186/// True when `url` is a non-empty origin worth injecting into widget CSP.
187///
188/// Local addresses (`localhost`, `127.0.0.1`) and empty strings are treated
189/// as "no public origin" — widgets submitted to ChatGPT or Claude can't reach
190/// them, and leaving localhost in the emitted CSP just ships garbage.
191pub(crate) fn is_public_proxy_origin(url: &str) -> bool {
192    !url.is_empty() && !url.contains("localhost") && !url.contains("127.0.0.1")
193}
194
195impl CspConfig {
196    fn policy(&self, d: Directive) -> &DirectivePolicy {
197        match d {
198            Directive::Connect => &self.connect_domains,
199            Directive::Resource => &self.resource_domains,
200            Directive::Frame => &self.frame_domains,
201        }
202    }
203}
204
205/// Compute the effective domain list for one directive.
206///
207/// - `upstream_domains` are the values the MCP server declared for this
208///   directive; they pass through only when the global mode is `Extend`.
209/// - `resource_uri` selects which `[[csp.widget]]` overrides apply.
210/// - `upstream_host` is the bare host (no scheme) used to strip upstream
211///   self-references that would leak localhost into the proxied CSP.
212/// - `proxy_url` is prepended for `connect` and `resource` so widgets can
213///   reach the proxy for API calls and asset loads. It is NOT prepended for
214///   `frame` — widgets don't iframe the proxy back into themselves, and
215///   including it there makes every widget look like an iframe-embedder.
216pub fn effective_domains(
217    cfg: &CspConfig,
218    directive: Directive,
219    resource_uri: Option<&str>,
220    upstream_domains: &[String],
221    upstream_host: &str,
222    proxy_url: &str,
223) -> Vec<String> {
224    let global = cfg.policy(directive);
225
226    // 1. Seed from upstream unless the global mode replaces it.
227    let mut base: Vec<String> = if global.mode == Mode::Replace {
228        Vec::new()
229    } else {
230        upstream_domains
231            .iter()
232            .filter(|d| !is_self_reference(d, upstream_host))
233            .cloned()
234            .collect()
235    };
236
237    // 2. Always add the globally declared domains.
238    for d in &global.domains {
239        push_unique(&mut base, d);
240    }
241
242    // 3. Walk widget overrides in config order. A widget with empty domains
243    //    and extend mode is a no-op; we skip it to keep the diff clean.
244    if let Some(uri) = resource_uri {
245        for w in &cfg.widgets {
246            if !glob_match(&w.match_pattern, uri) {
247                continue;
248            }
249            let (domains, mode) = w.for_directive(directive);
250            if domains.is_empty() && mode == Mode::Extend {
251                continue;
252            }
253            if mode == Mode::Replace {
254                base = domains.to_vec();
255            } else {
256                for d in domains {
257                    push_unique(&mut base, d);
258                }
259            }
260        }
261    }
262
263    // 4. Prepend the proxy URL for directives that need to reach the proxy
264    //    itself (API calls, asset loads). Frame is intentionally skipped —
265    //    see module docs. The prepend is also suppressed when `proxy_url`
266    //    is empty or loopback: widgets shipped to ChatGPT/Claude can't reach
267    //    a local dev address, so leaving it in the CSP just pollutes the
268    //    submitted template. Dedupe preserving first-seen order.
269    let mut out = if directive == Directive::Frame || !is_public_proxy_origin(proxy_url) {
270        Vec::new()
271    } else {
272        vec![proxy_url.to_string()]
273    };
274    for d in base {
275        push_unique(&mut out, &d);
276    }
277    out
278}
279
280fn push_unique(list: &mut Vec<String>, value: &str) {
281    if !list.iter().any(|s| s == value) {
282        list.push(value.to_string());
283    }
284}
285
286/// Returns true if `domain` points at the upstream MCP server itself or at
287/// a local loopback address. These values only make sense in dev; the proxy
288/// replaces them with its own URL so the widget reaches the proxy instead.
289fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
290    if domain.contains("localhost") || domain.contains("127.0.0.1") {
291        return true;
292    }
293    !upstream_host.is_empty() && domain.contains(upstream_host)
294}
295
296/// Minimal glob matcher over bytes. Supports `*` (any sequence) and `?`
297/// (single character). Everything else matches literally.
298pub fn glob_match(pattern: &str, input: &str) -> bool {
299    glob_rec(pattern.as_bytes(), input.as_bytes())
300}
301
302fn glob_rec(p: &[u8], t: &[u8]) -> bool {
303    if p.is_empty() {
304        return t.is_empty();
305    }
306    if p[0] == b'*' {
307        // `*` matches zero or more characters; try consuming none first, then
308        // consume one from the input and retry. Patterns are short (tens of
309        // bytes) so the recursion depth stays bounded.
310        if glob_rec(&p[1..], t) {
311            return true;
312        }
313        if !t.is_empty() {
314            return glob_rec(p, &t[1..]);
315        }
316        return false;
317    }
318    if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
319        return glob_rec(&p[1..], &t[1..]);
320    }
321    false
322}
323
324#[cfg(test)]
325#[allow(non_snake_case)]
326mod tests {
327    use super::*;
328
329    // ── helpers ────────────────────────────────────────────────────────────
330
331    fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
332        DirectivePolicy {
333            domains: domains.iter().map(|s| s.to_string()).collect(),
334            mode,
335        }
336    }
337
338    fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
339        WidgetScoped {
340            match_pattern: pattern.to_string(),
341            connect_domains: connect.iter().map(|s| s.to_string()).collect(),
342            connect_domains_mode: mode,
343            ..Default::default()
344        }
345    }
346
347    fn domains(items: &[&str]) -> Vec<String> {
348        items.iter().map(|s| s.to_string()).collect()
349    }
350
351    // ── Mode serde ─────────────────────────────────────────────────────────
352
353    #[test]
354    fn mode__deserialises_extend() {
355        let m: Mode = serde_json::from_str("\"extend\"").unwrap();
356        assert_eq!(m, Mode::Extend);
357    }
358
359    #[test]
360    fn mode__deserialises_replace() {
361        let m: Mode = serde_json::from_str("\"replace\"").unwrap();
362        assert_eq!(m, Mode::Replace);
363    }
364
365    #[test]
366    fn mode__default_is_extend() {
367        assert_eq!(Mode::default(), Mode::Extend);
368    }
369
370    // ── CspConfig defaults ─────────────────────────────────────────────────
371
372    #[test]
373    fn csp_config__default_strict_frames() {
374        let c = CspConfig::default();
375        assert_eq!(c.connect_domains.mode, Mode::Extend);
376        assert_eq!(c.resource_domains.mode, Mode::Extend);
377        assert_eq!(c.frame_domains.mode, Mode::Replace);
378    }
379
380    // ── effective_domains: global extend ───────────────────────────────────
381
382    #[test]
383    fn effective__extend_keeps_external_drops_upstream_host() {
384        let cfg = CspConfig::default();
385        let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
386
387        let out = effective_domains(
388            &cfg,
389            Directive::Connect,
390            None,
391            &upstream,
392            "localhost:9000",
393            "https://proxy.example.com",
394        );
395
396        assert_eq!(
397            out,
398            domains(&["https://proxy.example.com", "https://api.external.com"])
399        );
400    }
401
402    #[test]
403    fn effective__extend_adds_global_domains() {
404        let cfg = CspConfig {
405            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
406            ..CspConfig::default()
407        };
408
409        let out = effective_domains(
410            &cfg,
411            Directive::Connect,
412            None,
413            &domains(&["https://api.external.com"]),
414            "upstream.internal",
415            "https://proxy.example.com",
416        );
417
418        assert_eq!(
419            out,
420            domains(&[
421                "https://proxy.example.com",
422                "https://api.external.com",
423                "https://api.mine.com",
424            ])
425        );
426    }
427
428    // ── effective_domains: global replace ──────────────────────────────────
429
430    #[test]
431    fn effective__replace_ignores_upstream() {
432        let cfg = CspConfig {
433            connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
434            ..CspConfig::default()
435        };
436
437        let out = effective_domains(
438            &cfg,
439            Directive::Connect,
440            None,
441            &domains(&["https://api.external.com"]),
442            "upstream.internal",
443            "https://proxy.example.com",
444        );
445
446        assert_eq!(
447            out,
448            domains(&["https://proxy.example.com", "https://api.mine.com"])
449        );
450    }
451
452    #[test]
453    fn effective__replace_with_empty_global_leaves_only_proxy() {
454        let cfg = CspConfig {
455            connect_domains: policy(&[], Mode::Replace),
456            ..CspConfig::default()
457        };
458
459        let out = effective_domains(
460            &cfg,
461            Directive::Connect,
462            None,
463            &domains(&["https://api.external.com"]),
464            "upstream.internal",
465            "https://proxy.example.com",
466        );
467
468        assert_eq!(out, domains(&["https://proxy.example.com"]));
469    }
470
471    // ── effective_domains: frame does not get the proxy URL ───────────────
472
473    #[test]
474    fn effective__frame_directive_default_is_empty_not_proxy() {
475        // Frame defaults to strict replace with empty domains. Unlike connect
476        // and resource, we must NOT prepend the proxy URL — widgets don't
477        // iframe the proxy back into themselves, and including it flags the
478        // widget as an iframe-embedder to hosts like ChatGPT.
479        let cfg = CspConfig::default();
480        let out = effective_domains(
481            &cfg,
482            Directive::Frame,
483            None,
484            &domains(&["https://embed.external.com"]),
485            "upstream.internal",
486            "https://proxy.example.com",
487        );
488
489        assert!(out.is_empty(), "expected empty, got {out:?}");
490    }
491
492    #[test]
493    fn effective__frame_directive_with_declared_domains_omits_proxy() {
494        // When the operator declares frame domains, they pass through verbatim
495        // — still no proxy URL prefix.
496        let cfg = CspConfig {
497            frame_domains: policy(&["https://embed.partner.com"], Mode::Extend),
498            ..CspConfig::default()
499        };
500        let out = effective_domains(
501            &cfg,
502            Directive::Frame,
503            None,
504            &[],
505            "upstream.internal",
506            "https://proxy.example.com",
507        );
508
509        assert_eq!(out, domains(&["https://embed.partner.com"]));
510    }
511
512    #[test]
513    fn effective__connect_and_resource_still_get_proxy_prepend() {
514        // Regression guard: the frame-skip must not affect connect/resource.
515        let cfg = CspConfig::default();
516        for directive in [Directive::Connect, Directive::Resource] {
517            let out = effective_domains(
518                &cfg,
519                directive,
520                None,
521                &[],
522                "upstream.internal",
523                "https://proxy.example.com",
524            );
525            assert_eq!(
526                out,
527                domains(&["https://proxy.example.com"]),
528                "directive {directive:?}"
529            );
530        }
531    }
532
533    // ── effective_domains: widget extend ───────────────────────────────────
534
535    #[test]
536    fn effective__widget_extend_adds_on_top_of_global() {
537        let cfg = CspConfig {
538            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
539            widgets: vec![widget(
540                "ui://widget/payment*",
541                &["https://api.stripe.com"],
542                Mode::Extend,
543            )],
544            ..CspConfig::default()
545        };
546
547        let out = effective_domains(
548            &cfg,
549            Directive::Connect,
550            Some("ui://widget/payment-form"),
551            &[],
552            "upstream.internal",
553            "https://proxy.example.com",
554        );
555
556        assert_eq!(
557            out,
558            domains(&[
559                "https://proxy.example.com",
560                "https://api.mine.com",
561                "https://api.stripe.com",
562            ])
563        );
564    }
565
566    #[test]
567    fn effective__widget_with_no_matching_uri_is_ignored() {
568        let cfg = CspConfig {
569            widgets: vec![widget(
570                "ui://widget/payment*",
571                &["https://api.stripe.com"],
572                Mode::Extend,
573            )],
574            ..CspConfig::default()
575        };
576
577        let out = effective_domains(
578            &cfg,
579            Directive::Connect,
580            Some("ui://widget/search"),
581            &[],
582            "upstream.internal",
583            "https://proxy.example.com",
584        );
585
586        assert_eq!(out, domains(&["https://proxy.example.com"]));
587    }
588
589    #[test]
590    fn effective__widget_without_uri_context_falls_back_to_global() {
591        let cfg = CspConfig {
592            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
593            widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
594            ..CspConfig::default()
595        };
596
597        let out = effective_domains(
598            &cfg,
599            Directive::Connect,
600            None,
601            &[],
602            "upstream.internal",
603            "https://proxy.example.com",
604        );
605
606        assert_eq!(
607            out,
608            domains(&["https://proxy.example.com", "https://api.mine.com"])
609        );
610    }
611
612    // ── effective_domains: widget replace ──────────────────────────────────
613
614    #[test]
615    fn effective__widget_replace_wipes_everything_before_it() {
616        let cfg = CspConfig {
617            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
618            widgets: vec![widget(
619                "ui://widget/payment*",
620                &["https://api.stripe.com"],
621                Mode::Replace,
622            )],
623            ..CspConfig::default()
624        };
625
626        let out = effective_domains(
627            &cfg,
628            Directive::Connect,
629            Some("ui://widget/payment-form"),
630            &domains(&["https://api.external.com"]),
631            "upstream.internal",
632            "https://proxy.example.com",
633        );
634
635        assert_eq!(
636            out,
637            domains(&["https://proxy.example.com", "https://api.stripe.com"])
638        );
639    }
640
641    #[test]
642    fn effective__widget_replace_with_empty_domains_clears_list() {
643        let cfg = CspConfig {
644            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
645            widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
646            ..CspConfig::default()
647        };
648
649        let out = effective_domains(
650            &cfg,
651            Directive::Connect,
652            Some("ui://widget/anything"),
653            &domains(&["https://api.external.com"]),
654            "upstream.internal",
655            "https://proxy.example.com",
656        );
657
658        assert_eq!(out, domains(&["https://proxy.example.com"]));
659    }
660
661    #[test]
662    fn effective__widget_extend_with_empty_domains_is_noop() {
663        // An empty + extend widget entry must not change anything, even when
664        // it matches the URI. Operators use this shape when they set modes
665        // for some directives but not others on the same widget block.
666        let cfg = CspConfig {
667            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
668            widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
669            ..CspConfig::default()
670        };
671
672        let out = effective_domains(
673            &cfg,
674            Directive::Connect,
675            Some("ui://widget/anything"),
676            &[],
677            "upstream.internal",
678            "https://proxy.example.com",
679        );
680
681        assert_eq!(
682            out,
683            domains(&["https://proxy.example.com", "https://api.mine.com"])
684        );
685    }
686
687    // ── effective_domains: widget ordering ────────────────────────────────
688
689    #[test]
690    fn effective__multiple_matching_widgets_apply_in_config_order() {
691        let cfg = CspConfig {
692            widgets: vec![
693                widget("ui://widget/*", &["https://a.com"], Mode::Extend),
694                widget("ui://widget/*", &["https://b.com"], Mode::Replace),
695                widget("ui://widget/*", &["https://c.com"], Mode::Extend),
696            ],
697            ..CspConfig::default()
698        };
699
700        let out = effective_domains(
701            &cfg,
702            Directive::Connect,
703            Some("ui://widget/anything"),
704            &[],
705            "upstream.internal",
706            "https://proxy.example.com",
707        );
708
709        // First widget extends with a.com, second wipes and sets b.com,
710        // third extends with c.com. Proxy URL is always prepended last.
711        assert_eq!(
712            out,
713            domains(&[
714                "https://proxy.example.com",
715                "https://b.com",
716                "https://c.com"
717            ])
718        );
719    }
720
721    // ── effective_domains: dedupe ──────────────────────────────────────────
722
723    #[test]
724    fn effective__dedupes_across_sources() {
725        let cfg = CspConfig {
726            connect_domains: policy(&["https://shared.com"], Mode::Extend),
727            widgets: vec![widget(
728                "ui://widget/*",
729                &["https://shared.com"],
730                Mode::Extend,
731            )],
732            ..CspConfig::default()
733        };
734
735        let out = effective_domains(
736            &cfg,
737            Directive::Connect,
738            Some("ui://widget/x"),
739            &domains(&["https://shared.com"]),
740            "upstream.internal",
741            "https://proxy.example.com",
742        );
743
744        assert_eq!(
745            out,
746            domains(&["https://proxy.example.com", "https://shared.com"])
747        );
748    }
749
750    #[test]
751    fn effective__dedupes_proxy_url_already_in_upstream() {
752        let cfg = CspConfig::default();
753        let out = effective_domains(
754            &cfg,
755            Directive::Connect,
756            None,
757            &domains(&["https://proxy.example.com", "https://api.external.com"]),
758            "upstream.internal",
759            "https://proxy.example.com",
760        );
761        let count = out
762            .iter()
763            .filter(|d| *d == "https://proxy.example.com")
764            .count();
765        assert_eq!(count, 1);
766    }
767
768    // ── self-reference stripping ───────────────────────────────────────────
769
770    #[test]
771    fn effective__strips_localhost() {
772        let cfg = CspConfig::default();
773        let out = effective_domains(
774            &cfg,
775            Directive::Connect,
776            None,
777            &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
778            "upstream.internal",
779            "https://proxy.example.com",
780        );
781        assert_eq!(out, domains(&["https://proxy.example.com"]));
782    }
783
784    #[test]
785    fn effective__strips_upstream_host() {
786        let cfg = CspConfig::default();
787        let out = effective_domains(
788            &cfg,
789            Directive::Connect,
790            None,
791            &domains(&["https://upstream.internal", "https://api.external.com"]),
792            "upstream.internal",
793            "https://proxy.example.com",
794        );
795        assert_eq!(
796            out,
797            domains(&["https://proxy.example.com", "https://api.external.com"])
798        );
799    }
800
801    #[test]
802    fn effective__empty_upstream_host_disables_self_stripping() {
803        // When the upstream host is unknown (empty), only localhost heuristics
804        // remain. External domains pass through.
805        let cfg = CspConfig::default();
806        let out = effective_domains(
807            &cfg,
808            Directive::Connect,
809            None,
810            &domains(&["https://api.external.com"]),
811            "",
812            "https://proxy.example.com",
813        );
814        assert_eq!(
815            out,
816            domains(&["https://proxy.example.com", "https://api.external.com"])
817        );
818    }
819
820    // ── glob_match ─────────────────────────────────────────────────────────
821
822    #[test]
823    fn glob__literal_match() {
824        assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
825    }
826
827    #[test]
828    fn glob__literal_mismatch() {
829        assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
830    }
831
832    #[test]
833    fn glob__star_matches_suffix() {
834        assert!(glob_match(
835            "ui://widget/payment*",
836            "ui://widget/payment-form"
837        ));
838        assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
839    }
840
841    #[test]
842    fn glob__star_matches_any_sequence() {
843        assert!(glob_match("ui://*/payment", "ui://widget/payment"));
844        assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
845    }
846
847    #[test]
848    fn glob__double_star_segment() {
849        assert!(glob_match("ui://widget/*", "ui://widget/anything"));
850    }
851
852    #[test]
853    fn glob__question_matches_single_char() {
854        assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
855        assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
856    }
857
858    #[test]
859    fn glob__empty_pattern_matches_empty_string_only() {
860        assert!(glob_match("", ""));
861        assert!(!glob_match("", "anything"));
862    }
863
864    #[test]
865    fn glob__star_only_matches_anything() {
866        assert!(glob_match("*", ""));
867        assert!(glob_match("*", "anything"));
868    }
869
870    // ── Mode Display ───────────────────────────────────────────────────────
871
872    #[test]
873    fn mode__display() {
874        assert_eq!(Mode::Extend.to_string(), "extend");
875        assert_eq!(Mode::Replace.to_string(), "replace");
876    }
877
878    // ── is_public_proxy_origin ─────────────────────────────────────────────
879
880    #[test]
881    fn public_origin__accepts_https_host() {
882        assert!(is_public_proxy_origin("https://widgets.example.com"));
883    }
884
885    #[test]
886    fn public_origin__rejects_empty() {
887        assert!(!is_public_proxy_origin(""));
888    }
889
890    #[test]
891    fn public_origin__rejects_localhost() {
892        assert!(!is_public_proxy_origin("http://localhost:9002"));
893    }
894
895    #[test]
896    fn public_origin__rejects_loopback_ip() {
897        assert!(!is_public_proxy_origin("http://127.0.0.1:9002"));
898    }
899
900    // ── effective_domains: localhost suppression ───────────────────────────
901
902    #[test]
903    fn effective__skips_prepend_when_proxy_is_localhost() {
904        // Local-only dev: we declared resource domains but no public origin
905        // is available. The emitted list must NOT contain the loopback URL —
906        // a submitted template should never ship `localhost` in its CSP.
907        let cfg = CspConfig {
908            resource_domains: DirectivePolicy {
909                domains: domains(&["https://cdn.example.com"]),
910                mode: Mode::Extend,
911            },
912            ..CspConfig::default()
913        };
914        let out = effective_domains(
915            &cfg,
916            Directive::Resource,
917            None,
918            &[],
919            "",
920            "http://localhost:9002",
921        );
922        assert_eq!(out, domains(&["https://cdn.example.com"]));
923    }
924
925    #[test]
926    fn effective__skips_prepend_when_proxy_url_empty() {
927        let cfg = CspConfig {
928            connect_domains: DirectivePolicy {
929                domains: domains(&["https://api.example.com"]),
930                mode: Mode::Extend,
931            },
932            ..CspConfig::default()
933        };
934        let out = effective_domains(&cfg, Directive::Connect, None, &[], "", "");
935        assert_eq!(out, domains(&["https://api.example.com"]));
936    }
937}