use serde::Deserialize;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
#[default]
Extend,
Replace,
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Mode::Extend => write!(f, "extend"),
Mode::Replace => write!(f, "replace"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Directive {
Connect,
Resource,
Frame,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct DirectivePolicy {
pub domains: Vec<String>,
pub mode: Mode,
}
impl DirectivePolicy {
pub fn strict() -> Self {
Self {
domains: Vec::new(),
mode: Mode::Replace,
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct WidgetScoped {
#[serde(rename = "match")]
pub match_pattern: String,
#[serde(rename = "connectDomains")]
pub connect_domains: Vec<String>,
#[serde(rename = "connectDomainsMode")]
pub connect_domains_mode: Mode,
#[serde(rename = "resourceDomains")]
pub resource_domains: Vec<String>,
#[serde(rename = "resourceDomainsMode")]
pub resource_domains_mode: Mode,
#[serde(rename = "frameDomains")]
pub frame_domains: Vec<String>,
#[serde(rename = "frameDomainsMode")]
pub frame_domains_mode: Mode,
}
impl WidgetScoped {
fn for_directive(&self, d: Directive) -> (&[String], Mode) {
match d {
Directive::Connect => (&self.connect_domains, self.connect_domains_mode),
Directive::Resource => (&self.resource_domains, self.resource_domains_mode),
Directive::Frame => (&self.frame_domains, self.frame_domains_mode),
}
}
}
#[derive(Clone, Debug)]
pub struct CspConfig {
pub connect_domains: DirectivePolicy,
pub resource_domains: DirectivePolicy,
pub frame_domains: DirectivePolicy,
pub widgets: Vec<WidgetScoped>,
}
impl Default for CspConfig {
fn default() -> Self {
Self {
connect_domains: DirectivePolicy::default(),
resource_domains: DirectivePolicy::default(),
frame_domains: DirectivePolicy::strict(),
widgets: Vec::new(),
}
}
}
impl CspConfig {
fn policy(&self, d: Directive) -> &DirectivePolicy {
match d {
Directive::Connect => &self.connect_domains,
Directive::Resource => &self.resource_domains,
Directive::Frame => &self.frame_domains,
}
}
}
pub fn effective_domains(
cfg: &CspConfig,
directive: Directive,
resource_uri: Option<&str>,
upstream_domains: &[String],
upstream_host: &str,
proxy_url: &str,
) -> Vec<String> {
let global = cfg.policy(directive);
let mut base: Vec<String> = if global.mode == Mode::Replace {
Vec::new()
} else {
upstream_domains
.iter()
.filter(|d| !is_self_reference(d, upstream_host))
.cloned()
.collect()
};
for d in &global.domains {
push_unique(&mut base, d);
}
if let Some(uri) = resource_uri {
for w in &cfg.widgets {
if !glob_match(&w.match_pattern, uri) {
continue;
}
let (domains, mode) = w.for_directive(directive);
if domains.is_empty() && mode == Mode::Extend {
continue;
}
if mode == Mode::Replace {
base = domains.to_vec();
} else {
for d in domains {
push_unique(&mut base, d);
}
}
}
}
let mut out = vec![proxy_url.to_string()];
for d in base {
push_unique(&mut out, &d);
}
out
}
fn push_unique(list: &mut Vec<String>, value: &str) {
if !list.iter().any(|s| s == value) {
list.push(value.to_string());
}
}
fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
if domain.contains("localhost") || domain.contains("127.0.0.1") {
return true;
}
!upstream_host.is_empty() && domain.contains(upstream_host)
}
pub fn glob_match(pattern: &str, input: &str) -> bool {
glob_rec(pattern.as_bytes(), input.as_bytes())
}
fn glob_rec(p: &[u8], t: &[u8]) -> bool {
if p.is_empty() {
return t.is_empty();
}
if p[0] == b'*' {
if glob_rec(&p[1..], t) {
return true;
}
if !t.is_empty() {
return glob_rec(p, &t[1..]);
}
return false;
}
if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
return glob_rec(&p[1..], &t[1..]);
}
false
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use super::*;
fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
DirectivePolicy {
domains: domains.iter().map(|s| s.to_string()).collect(),
mode,
}
}
fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
WidgetScoped {
match_pattern: pattern.to_string(),
connect_domains: connect.iter().map(|s| s.to_string()).collect(),
connect_domains_mode: mode,
..Default::default()
}
}
fn domains(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn mode__deserialises_extend() {
let m: Mode = serde_json::from_str("\"extend\"").unwrap();
assert_eq!(m, Mode::Extend);
}
#[test]
fn mode__deserialises_replace() {
let m: Mode = serde_json::from_str("\"replace\"").unwrap();
assert_eq!(m, Mode::Replace);
}
#[test]
fn mode__default_is_extend() {
assert_eq!(Mode::default(), Mode::Extend);
}
#[test]
fn csp_config__default_strict_frames() {
let c = CspConfig::default();
assert_eq!(c.connect_domains.mode, Mode::Extend);
assert_eq!(c.resource_domains.mode, Mode::Extend);
assert_eq!(c.frame_domains.mode, Mode::Replace);
}
#[test]
fn effective__extend_keeps_external_drops_upstream_host() {
let cfg = CspConfig::default();
let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&upstream,
"localhost:9000",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.external.com"])
);
}
#[test]
fn effective__extend_adds_global_domains() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&[
"https://proxy.example.com",
"https://api.external.com",
"https://api.mine.com",
])
);
}
#[test]
fn effective__replace_ignores_upstream() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.mine.com"])
);
}
#[test]
fn effective__replace_with_empty_global_leaves_only_proxy() {
let cfg = CspConfig {
connect_domains: policy(&[], Mode::Replace),
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(out, domains(&["https://proxy.example.com"]));
}
#[test]
fn effective__widget_extend_adds_on_top_of_global() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
widgets: vec![widget(
"ui://widget/payment*",
&["https://api.stripe.com"],
Mode::Extend,
)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/payment-form"),
&[],
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&[
"https://proxy.example.com",
"https://api.mine.com",
"https://api.stripe.com",
])
);
}
#[test]
fn effective__widget_with_no_matching_uri_is_ignored() {
let cfg = CspConfig {
widgets: vec![widget(
"ui://widget/payment*",
&["https://api.stripe.com"],
Mode::Extend,
)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/search"),
&[],
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(out, domains(&["https://proxy.example.com"]));
}
#[test]
fn effective__widget_without_uri_context_falls_back_to_global() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&[],
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.mine.com"])
);
}
#[test]
fn effective__widget_replace_wipes_everything_before_it() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
widgets: vec![widget(
"ui://widget/payment*",
&["https://api.stripe.com"],
Mode::Replace,
)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/payment-form"),
&domains(&["https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.stripe.com"])
);
}
#[test]
fn effective__widget_replace_with_empty_domains_clears_list() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/anything"),
&domains(&["https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(out, domains(&["https://proxy.example.com"]));
}
#[test]
fn effective__widget_extend_with_empty_domains_is_noop() {
let cfg = CspConfig {
connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/anything"),
&[],
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.mine.com"])
);
}
#[test]
fn effective__multiple_matching_widgets_apply_in_config_order() {
let cfg = CspConfig {
widgets: vec![
widget("ui://widget/*", &["https://a.com"], Mode::Extend),
widget("ui://widget/*", &["https://b.com"], Mode::Replace),
widget("ui://widget/*", &["https://c.com"], Mode::Extend),
],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/anything"),
&[],
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&[
"https://proxy.example.com",
"https://b.com",
"https://c.com"
])
);
}
#[test]
fn effective__dedupes_across_sources() {
let cfg = CspConfig {
connect_domains: policy(&["https://shared.com"], Mode::Extend),
widgets: vec![widget(
"ui://widget/*",
&["https://shared.com"],
Mode::Extend,
)],
..CspConfig::default()
};
let out = effective_domains(
&cfg,
Directive::Connect,
Some("ui://widget/x"),
&domains(&["https://shared.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://shared.com"])
);
}
#[test]
fn effective__dedupes_proxy_url_already_in_upstream() {
let cfg = CspConfig::default();
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://proxy.example.com", "https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
let count = out
.iter()
.filter(|d| *d == "https://proxy.example.com")
.count();
assert_eq!(count, 1);
}
#[test]
fn effective__strips_localhost() {
let cfg = CspConfig::default();
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(out, domains(&["https://proxy.example.com"]));
}
#[test]
fn effective__strips_upstream_host() {
let cfg = CspConfig::default();
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://upstream.internal", "https://api.external.com"]),
"upstream.internal",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.external.com"])
);
}
#[test]
fn effective__empty_upstream_host_disables_self_stripping() {
let cfg = CspConfig::default();
let out = effective_domains(
&cfg,
Directive::Connect,
None,
&domains(&["https://api.external.com"]),
"",
"https://proxy.example.com",
);
assert_eq!(
out,
domains(&["https://proxy.example.com", "https://api.external.com"])
);
}
#[test]
fn glob__literal_match() {
assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
}
#[test]
fn glob__literal_mismatch() {
assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
}
#[test]
fn glob__star_matches_suffix() {
assert!(glob_match(
"ui://widget/payment*",
"ui://widget/payment-form"
));
assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
}
#[test]
fn glob__star_matches_any_sequence() {
assert!(glob_match("ui://*/payment", "ui://widget/payment"));
assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
}
#[test]
fn glob__double_star_segment() {
assert!(glob_match("ui://widget/*", "ui://widget/anything"));
}
#[test]
fn glob__question_matches_single_char() {
assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
}
#[test]
fn glob__empty_pattern_matches_empty_string_only() {
assert!(glob_match("", ""));
assert!(!glob_match("", "anything"));
}
#[test]
fn glob__star_only_matches_anything() {
assert!(glob_match("*", ""));
assert!(glob_match("*", "anything"));
}
#[test]
fn mode__display() {
assert_eq!(Mode::Extend.to_string(), "extend");
assert_eq!(Mode::Replace.to_string(), "replace");
}
}