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. Prepend the proxy URL and dedupe.
34//!
35//! Replace semantics are scoped: a global replace only ignores upstream; a
36//! widget replace wipes everything accumulated above it.
37//!
38//! ## Example
39//!
40//! ```toml
41//! [csp.connectDomains]
42//! domains = ["api.example.com"]
43//! mode    = "extend"
44//!
45//! [csp.resourceDomains]
46//! domains = ["cdn.example.com"]
47//! mode    = "extend"
48//!
49//! [csp.frameDomains]
50//! domains = []
51//! mode    = "replace"
52//!
53//! [[csp.widget]]
54//! match              = "ui://widget/payment*"
55//! connectDomains     = ["api.stripe.com"]
56//! connectDomainsMode = "extend"
57//! ```
58
59use serde::Deserialize;
60
61/// Merge mode for a single CSP directive.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Mode {
65    /// Combine the directive's declared domains with upstream.
66    #[default]
67    Extend,
68    /// Ignore upstream for this directive; use only declared domains.
69    Replace,
70}
71
72impl std::fmt::Display for Mode {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Mode::Extend => write!(f, "extend"),
76            Mode::Replace => write!(f, "replace"),
77        }
78    }
79}
80
81/// Which of the three CSP directive arrays a policy targets.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum Directive {
84    Connect,
85    Resource,
86    Frame,
87}
88
89/// A domain list paired with a merge mode.
90///
91/// Empty `domains` combined with `Mode::Extend` is a no-op. Empty `domains`
92/// combined with `Mode::Replace` explicitly clears the accumulated list.
93#[derive(Clone, Debug, Default, Deserialize)]
94#[serde(default)]
95pub struct DirectivePolicy {
96    pub domains: Vec<String>,
97    pub mode: Mode,
98}
99
100impl DirectivePolicy {
101    /// Build a policy that clears the accumulated list — the default for
102    /// `frameDomains`, which fails closed unless the operator opts in.
103    pub fn strict() -> Self {
104        Self {
105            domains: Vec::new(),
106            mode: Mode::Replace,
107        }
108    }
109}
110
111/// Per-widget override matched by glob on resource URI.
112///
113/// Directives are addressed as two paired fields: a domains list and a mode.
114/// Omitting both pairs for a directive leaves that directive untouched by the
115/// widget. Setting `mode = "replace"` with an empty domains list clears the
116/// accumulated domains for that directive.
117#[derive(Clone, Debug, Default, Deserialize)]
118#[serde(default)]
119pub struct WidgetScoped {
120    /// Glob pattern matched against a resource URI. `*` matches any sequence,
121    /// `?` matches one character. Literal everywhere else.
122    #[serde(rename = "match")]
123    pub match_pattern: String,
124
125    #[serde(rename = "connectDomains")]
126    pub connect_domains: Vec<String>,
127    #[serde(rename = "connectDomainsMode")]
128    pub connect_domains_mode: Mode,
129
130    #[serde(rename = "resourceDomains")]
131    pub resource_domains: Vec<String>,
132    #[serde(rename = "resourceDomainsMode")]
133    pub resource_domains_mode: Mode,
134
135    #[serde(rename = "frameDomains")]
136    pub frame_domains: Vec<String>,
137    #[serde(rename = "frameDomainsMode")]
138    pub frame_domains_mode: Mode,
139}
140
141impl WidgetScoped {
142    /// Fetch the (domains, mode) pair for one directive.
143    fn for_directive(&self, d: Directive) -> (&[String], Mode) {
144        match d {
145            Directive::Connect => (&self.connect_domains, self.connect_domains_mode),
146            Directive::Resource => (&self.resource_domains, self.resource_domains_mode),
147            Directive::Frame => (&self.frame_domains, self.frame_domains_mode),
148        }
149    }
150}
151
152/// Complete CSP configuration: three global directives plus widget overrides.
153#[derive(Clone, Debug)]
154pub struct CspConfig {
155    pub connect_domains: DirectivePolicy,
156    pub resource_domains: DirectivePolicy,
157    pub frame_domains: DirectivePolicy,
158    pub widgets: Vec<WidgetScoped>,
159}
160
161impl Default for CspConfig {
162    /// Defaults to permissive extend for connect and resource, and strict
163    /// replace for frame. Frames default strict because nested iframes are
164    /// rare in MCP widgets and the blast radius of an accidental allow is high.
165    fn default() -> Self {
166        Self {
167            connect_domains: DirectivePolicy::default(),
168            resource_domains: DirectivePolicy::default(),
169            frame_domains: DirectivePolicy::strict(),
170            widgets: Vec::new(),
171        }
172    }
173}
174
175impl CspConfig {
176    fn policy(&self, d: Directive) -> &DirectivePolicy {
177        match d {
178            Directive::Connect => &self.connect_domains,
179            Directive::Resource => &self.resource_domains,
180            Directive::Frame => &self.frame_domains,
181        }
182    }
183}
184
185/// Compute the effective domain list for one directive.
186///
187/// - `upstream_domains` are the values the MCP server declared for this
188///   directive; they pass through only when the global mode is `Extend`.
189/// - `resource_uri` selects which `[[csp.widget]]` overrides apply.
190/// - `upstream_host` is the bare host (no scheme) used to strip upstream
191///   self-references that would leak localhost into the proxied CSP.
192/// - `proxy_url` is always prepended so hosts let the widget reach the proxy.
193pub fn effective_domains(
194    cfg: &CspConfig,
195    directive: Directive,
196    resource_uri: Option<&str>,
197    upstream_domains: &[String],
198    upstream_host: &str,
199    proxy_url: &str,
200) -> Vec<String> {
201    let global = cfg.policy(directive);
202
203    // 1. Seed from upstream unless the global mode replaces it.
204    let mut base: Vec<String> = if global.mode == Mode::Replace {
205        Vec::new()
206    } else {
207        upstream_domains
208            .iter()
209            .filter(|d| !is_self_reference(d, upstream_host))
210            .cloned()
211            .collect()
212    };
213
214    // 2. Always add the globally declared domains.
215    for d in &global.domains {
216        push_unique(&mut base, d);
217    }
218
219    // 3. Walk widget overrides in config order. A widget with empty domains
220    //    and extend mode is a no-op; we skip it to keep the diff clean.
221    if let Some(uri) = resource_uri {
222        for w in &cfg.widgets {
223            if !glob_match(&w.match_pattern, uri) {
224                continue;
225            }
226            let (domains, mode) = w.for_directive(directive);
227            if domains.is_empty() && mode == Mode::Extend {
228                continue;
229            }
230            if mode == Mode::Replace {
231                base = domains.to_vec();
232            } else {
233                for d in domains {
234                    push_unique(&mut base, d);
235                }
236            }
237        }
238    }
239
240    // 4. Prepend proxy URL so the widget can always reach the proxy itself,
241    //    then dedupe while preserving first-seen order.
242    let mut out = vec![proxy_url.to_string()];
243    for d in base {
244        push_unique(&mut out, &d);
245    }
246    out
247}
248
249fn push_unique(list: &mut Vec<String>, value: &str) {
250    if !list.iter().any(|s| s == value) {
251        list.push(value.to_string());
252    }
253}
254
255/// Returns true if `domain` points at the upstream MCP server itself or at
256/// a local loopback address. These values only make sense in dev; the proxy
257/// replaces them with its own URL so the widget reaches the proxy instead.
258fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
259    if domain.contains("localhost") || domain.contains("127.0.0.1") {
260        return true;
261    }
262    !upstream_host.is_empty() && domain.contains(upstream_host)
263}
264
265/// Minimal glob matcher over bytes. Supports `*` (any sequence) and `?`
266/// (single character). Everything else matches literally.
267pub fn glob_match(pattern: &str, input: &str) -> bool {
268    glob_rec(pattern.as_bytes(), input.as_bytes())
269}
270
271fn glob_rec(p: &[u8], t: &[u8]) -> bool {
272    if p.is_empty() {
273        return t.is_empty();
274    }
275    if p[0] == b'*' {
276        // `*` matches zero or more characters; try consuming none first, then
277        // consume one from the input and retry. Patterns are short (tens of
278        // bytes) so the recursion depth stays bounded.
279        if glob_rec(&p[1..], t) {
280            return true;
281        }
282        if !t.is_empty() {
283            return glob_rec(p, &t[1..]);
284        }
285        return false;
286    }
287    if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
288        return glob_rec(&p[1..], &t[1..]);
289    }
290    false
291}
292
293#[cfg(test)]
294#[allow(non_snake_case)]
295mod tests {
296    use super::*;
297
298    // ── helpers ────────────────────────────────────────────────────────────
299
300    fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
301        DirectivePolicy {
302            domains: domains.iter().map(|s| s.to_string()).collect(),
303            mode,
304        }
305    }
306
307    fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
308        WidgetScoped {
309            match_pattern: pattern.to_string(),
310            connect_domains: connect.iter().map(|s| s.to_string()).collect(),
311            connect_domains_mode: mode,
312            ..Default::default()
313        }
314    }
315
316    fn domains(items: &[&str]) -> Vec<String> {
317        items.iter().map(|s| s.to_string()).collect()
318    }
319
320    // ── Mode serde ─────────────────────────────────────────────────────────
321
322    #[test]
323    fn mode__deserialises_extend() {
324        let m: Mode = serde_json::from_str("\"extend\"").unwrap();
325        assert_eq!(m, Mode::Extend);
326    }
327
328    #[test]
329    fn mode__deserialises_replace() {
330        let m: Mode = serde_json::from_str("\"replace\"").unwrap();
331        assert_eq!(m, Mode::Replace);
332    }
333
334    #[test]
335    fn mode__default_is_extend() {
336        assert_eq!(Mode::default(), Mode::Extend);
337    }
338
339    // ── CspConfig defaults ─────────────────────────────────────────────────
340
341    #[test]
342    fn csp_config__default_strict_frames() {
343        let c = CspConfig::default();
344        assert_eq!(c.connect_domains.mode, Mode::Extend);
345        assert_eq!(c.resource_domains.mode, Mode::Extend);
346        assert_eq!(c.frame_domains.mode, Mode::Replace);
347    }
348
349    // ── effective_domains: global extend ───────────────────────────────────
350
351    #[test]
352    fn effective__extend_keeps_external_drops_upstream_host() {
353        let cfg = CspConfig::default();
354        let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
355
356        let out = effective_domains(
357            &cfg,
358            Directive::Connect,
359            None,
360            &upstream,
361            "localhost:9000",
362            "https://proxy.example.com",
363        );
364
365        assert_eq!(
366            out,
367            domains(&["https://proxy.example.com", "https://api.external.com"])
368        );
369    }
370
371    #[test]
372    fn effective__extend_adds_global_domains() {
373        let cfg = CspConfig {
374            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
375            ..CspConfig::default()
376        };
377
378        let out = effective_domains(
379            &cfg,
380            Directive::Connect,
381            None,
382            &domains(&["https://api.external.com"]),
383            "upstream.internal",
384            "https://proxy.example.com",
385        );
386
387        assert_eq!(
388            out,
389            domains(&[
390                "https://proxy.example.com",
391                "https://api.external.com",
392                "https://api.mine.com",
393            ])
394        );
395    }
396
397    // ── effective_domains: global replace ──────────────────────────────────
398
399    #[test]
400    fn effective__replace_ignores_upstream() {
401        let cfg = CspConfig {
402            connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
403            ..CspConfig::default()
404        };
405
406        let out = effective_domains(
407            &cfg,
408            Directive::Connect,
409            None,
410            &domains(&["https://api.external.com"]),
411            "upstream.internal",
412            "https://proxy.example.com",
413        );
414
415        assert_eq!(
416            out,
417            domains(&["https://proxy.example.com", "https://api.mine.com"])
418        );
419    }
420
421    #[test]
422    fn effective__replace_with_empty_global_leaves_only_proxy() {
423        let cfg = CspConfig {
424            connect_domains: policy(&[], Mode::Replace),
425            ..CspConfig::default()
426        };
427
428        let out = effective_domains(
429            &cfg,
430            Directive::Connect,
431            None,
432            &domains(&["https://api.external.com"]),
433            "upstream.internal",
434            "https://proxy.example.com",
435        );
436
437        assert_eq!(out, domains(&["https://proxy.example.com"]));
438    }
439
440    // ── effective_domains: widget extend ───────────────────────────────────
441
442    #[test]
443    fn effective__widget_extend_adds_on_top_of_global() {
444        let cfg = CspConfig {
445            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
446            widgets: vec![widget(
447                "ui://widget/payment*",
448                &["https://api.stripe.com"],
449                Mode::Extend,
450            )],
451            ..CspConfig::default()
452        };
453
454        let out = effective_domains(
455            &cfg,
456            Directive::Connect,
457            Some("ui://widget/payment-form"),
458            &[],
459            "upstream.internal",
460            "https://proxy.example.com",
461        );
462
463        assert_eq!(
464            out,
465            domains(&[
466                "https://proxy.example.com",
467                "https://api.mine.com",
468                "https://api.stripe.com",
469            ])
470        );
471    }
472
473    #[test]
474    fn effective__widget_with_no_matching_uri_is_ignored() {
475        let cfg = CspConfig {
476            widgets: vec![widget(
477                "ui://widget/payment*",
478                &["https://api.stripe.com"],
479                Mode::Extend,
480            )],
481            ..CspConfig::default()
482        };
483
484        let out = effective_domains(
485            &cfg,
486            Directive::Connect,
487            Some("ui://widget/search"),
488            &[],
489            "upstream.internal",
490            "https://proxy.example.com",
491        );
492
493        assert_eq!(out, domains(&["https://proxy.example.com"]));
494    }
495
496    #[test]
497    fn effective__widget_without_uri_context_falls_back_to_global() {
498        let cfg = CspConfig {
499            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
500            widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
501            ..CspConfig::default()
502        };
503
504        let out = effective_domains(
505            &cfg,
506            Directive::Connect,
507            None,
508            &[],
509            "upstream.internal",
510            "https://proxy.example.com",
511        );
512
513        assert_eq!(
514            out,
515            domains(&["https://proxy.example.com", "https://api.mine.com"])
516        );
517    }
518
519    // ── effective_domains: widget replace ──────────────────────────────────
520
521    #[test]
522    fn effective__widget_replace_wipes_everything_before_it() {
523        let cfg = CspConfig {
524            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
525            widgets: vec![widget(
526                "ui://widget/payment*",
527                &["https://api.stripe.com"],
528                Mode::Replace,
529            )],
530            ..CspConfig::default()
531        };
532
533        let out = effective_domains(
534            &cfg,
535            Directive::Connect,
536            Some("ui://widget/payment-form"),
537            &domains(&["https://api.external.com"]),
538            "upstream.internal",
539            "https://proxy.example.com",
540        );
541
542        assert_eq!(
543            out,
544            domains(&["https://proxy.example.com", "https://api.stripe.com"])
545        );
546    }
547
548    #[test]
549    fn effective__widget_replace_with_empty_domains_clears_list() {
550        let cfg = CspConfig {
551            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
552            widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
553            ..CspConfig::default()
554        };
555
556        let out = effective_domains(
557            &cfg,
558            Directive::Connect,
559            Some("ui://widget/anything"),
560            &domains(&["https://api.external.com"]),
561            "upstream.internal",
562            "https://proxy.example.com",
563        );
564
565        assert_eq!(out, domains(&["https://proxy.example.com"]));
566    }
567
568    #[test]
569    fn effective__widget_extend_with_empty_domains_is_noop() {
570        // An empty + extend widget entry must not change anything, even when
571        // it matches the URI. Operators use this shape when they set modes
572        // for some directives but not others on the same widget block.
573        let cfg = CspConfig {
574            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
575            widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
576            ..CspConfig::default()
577        };
578
579        let out = effective_domains(
580            &cfg,
581            Directive::Connect,
582            Some("ui://widget/anything"),
583            &[],
584            "upstream.internal",
585            "https://proxy.example.com",
586        );
587
588        assert_eq!(
589            out,
590            domains(&["https://proxy.example.com", "https://api.mine.com"])
591        );
592    }
593
594    // ── effective_domains: widget ordering ────────────────────────────────
595
596    #[test]
597    fn effective__multiple_matching_widgets_apply_in_config_order() {
598        let cfg = CspConfig {
599            widgets: vec![
600                widget("ui://widget/*", &["https://a.com"], Mode::Extend),
601                widget("ui://widget/*", &["https://b.com"], Mode::Replace),
602                widget("ui://widget/*", &["https://c.com"], Mode::Extend),
603            ],
604            ..CspConfig::default()
605        };
606
607        let out = effective_domains(
608            &cfg,
609            Directive::Connect,
610            Some("ui://widget/anything"),
611            &[],
612            "upstream.internal",
613            "https://proxy.example.com",
614        );
615
616        // First widget extends with a.com, second wipes and sets b.com,
617        // third extends with c.com. Proxy URL is always prepended last.
618        assert_eq!(
619            out,
620            domains(&[
621                "https://proxy.example.com",
622                "https://b.com",
623                "https://c.com"
624            ])
625        );
626    }
627
628    // ── effective_domains: dedupe ──────────────────────────────────────────
629
630    #[test]
631    fn effective__dedupes_across_sources() {
632        let cfg = CspConfig {
633            connect_domains: policy(&["https://shared.com"], Mode::Extend),
634            widgets: vec![widget(
635                "ui://widget/*",
636                &["https://shared.com"],
637                Mode::Extend,
638            )],
639            ..CspConfig::default()
640        };
641
642        let out = effective_domains(
643            &cfg,
644            Directive::Connect,
645            Some("ui://widget/x"),
646            &domains(&["https://shared.com"]),
647            "upstream.internal",
648            "https://proxy.example.com",
649        );
650
651        assert_eq!(
652            out,
653            domains(&["https://proxy.example.com", "https://shared.com"])
654        );
655    }
656
657    #[test]
658    fn effective__dedupes_proxy_url_already_in_upstream() {
659        let cfg = CspConfig::default();
660        let out = effective_domains(
661            &cfg,
662            Directive::Connect,
663            None,
664            &domains(&["https://proxy.example.com", "https://api.external.com"]),
665            "upstream.internal",
666            "https://proxy.example.com",
667        );
668        let count = out
669            .iter()
670            .filter(|d| *d == "https://proxy.example.com")
671            .count();
672        assert_eq!(count, 1);
673    }
674
675    // ── self-reference stripping ───────────────────────────────────────────
676
677    #[test]
678    fn effective__strips_localhost() {
679        let cfg = CspConfig::default();
680        let out = effective_domains(
681            &cfg,
682            Directive::Connect,
683            None,
684            &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
685            "upstream.internal",
686            "https://proxy.example.com",
687        );
688        assert_eq!(out, domains(&["https://proxy.example.com"]));
689    }
690
691    #[test]
692    fn effective__strips_upstream_host() {
693        let cfg = CspConfig::default();
694        let out = effective_domains(
695            &cfg,
696            Directive::Connect,
697            None,
698            &domains(&["https://upstream.internal", "https://api.external.com"]),
699            "upstream.internal",
700            "https://proxy.example.com",
701        );
702        assert_eq!(
703            out,
704            domains(&["https://proxy.example.com", "https://api.external.com"])
705        );
706    }
707
708    #[test]
709    fn effective__empty_upstream_host_disables_self_stripping() {
710        // When the upstream host is unknown (empty), only localhost heuristics
711        // remain. External domains pass through.
712        let cfg = CspConfig::default();
713        let out = effective_domains(
714            &cfg,
715            Directive::Connect,
716            None,
717            &domains(&["https://api.external.com"]),
718            "",
719            "https://proxy.example.com",
720        );
721        assert_eq!(
722            out,
723            domains(&["https://proxy.example.com", "https://api.external.com"])
724        );
725    }
726
727    // ── glob_match ─────────────────────────────────────────────────────────
728
729    #[test]
730    fn glob__literal_match() {
731        assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
732    }
733
734    #[test]
735    fn glob__literal_mismatch() {
736        assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
737    }
738
739    #[test]
740    fn glob__star_matches_suffix() {
741        assert!(glob_match(
742            "ui://widget/payment*",
743            "ui://widget/payment-form"
744        ));
745        assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
746    }
747
748    #[test]
749    fn glob__star_matches_any_sequence() {
750        assert!(glob_match("ui://*/payment", "ui://widget/payment"));
751        assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
752    }
753
754    #[test]
755    fn glob__double_star_segment() {
756        assert!(glob_match("ui://widget/*", "ui://widget/anything"));
757    }
758
759    #[test]
760    fn glob__question_matches_single_char() {
761        assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
762        assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
763    }
764
765    #[test]
766    fn glob__empty_pattern_matches_empty_string_only() {
767        assert!(glob_match("", ""));
768        assert!(!glob_match("", "anything"));
769    }
770
771    #[test]
772    fn glob__star_only_matches_anything() {
773        assert!(glob_match("*", ""));
774        assert!(glob_match("*", "anything"));
775    }
776
777    // ── Mode Display ───────────────────────────────────────────────────────
778
779    #[test]
780    fn mode__display() {
781        assert_eq!(Mode::Extend.to_string(), "extend");
782        assert_eq!(Mode::Replace.to_string(), "replace");
783    }
784}