use reqwest::Url;
#[derive(Clone, Debug, Default)]
pub enum AcceptPaymentPolicy {
#[default]
Always,
SameOrigin { same_origin: String },
Never,
Origins(Vec<String>),
}
impl AcceptPaymentPolicy {
pub fn allows(&self, url: &Url) -> bool {
match self {
Self::Always => true,
Self::Never => false,
Self::SameOrigin { same_origin } => match Url::parse(same_origin) {
Ok(parsed) => url.origin() == parsed.origin(),
Err(_) => false,
},
Self::Origins(patterns) => patterns.iter().any(|p| matches_origin(url, p)),
}
}
}
fn matches_origin(url: &Url, pattern: &str) -> bool {
if let Some(suffix_no_dot) = pattern.strip_prefix("*.") {
let url_host = match url.host_str() {
Some(h) => h.to_ascii_lowercase(),
None => return false,
};
let suffix = suffix_no_dot.to_ascii_lowercase();
return url_host == suffix || url_host.ends_with(&format!(".{suffix}"));
}
match Url::parse(pattern) {
Ok(pattern_url) => url.origin() == pattern_url.origin(),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn always_allows_any_url() {
let p = AcceptPaymentPolicy::Always;
assert!(p.allows(&url("https://example.com/api")));
assert!(p.allows(&url("http://localhost:8080/")));
assert!(p.allows(&url("https://cross-origin.example.com/")));
}
#[test]
fn never_blocks_any_url() {
let p = AcceptPaymentPolicy::Never;
assert!(!p.allows(&url("https://example.com/api")));
assert!(!p.allows(&url("http://localhost:8080/")));
}
#[test]
fn default_is_always() {
let p = AcceptPaymentPolicy::default();
assert!(p.allows(&url("https://example.com/")));
}
#[test]
fn same_origin_matches_exact_origin() {
let p = AcceptPaymentPolicy::SameOrigin {
same_origin: "https://app.example.com".to_string(),
};
assert!(p.allows(&url("https://app.example.com/api")));
assert!(p.allows(&url("https://app.example.com/")));
assert!(!p.allows(&url("https://other.example.com/api")));
assert!(!p.allows(&url("http://app.example.com/api"))); }
#[test]
fn same_origin_respects_port() {
let p = AcceptPaymentPolicy::SameOrigin {
same_origin: "http://localhost:3000".to_string(),
};
assert!(p.allows(&url("http://localhost:3000/api")));
assert!(!p.allows(&url("http://localhost:3001/api")));
assert!(!p.allows(&url("http://localhost/api")));
}
#[test]
fn origins_exact_match() {
let p = AcceptPaymentPolicy::Origins(vec!["https://app.example.com".to_string()]);
assert!(p.allows(&url("https://app.example.com/api")));
assert!(!p.allows(&url("https://other.com/api")));
}
#[test]
fn origins_wildcard_subdomain() {
let p = AcceptPaymentPolicy::Origins(vec!["*.example.com".to_string()]);
assert!(p.allows(&url("https://api.example.com/")));
assert!(p.allows(&url("https://x.y.example.com/")));
assert!(p.allows(&url("https://example.com/"))); assert!(!p.allows(&url("https://example.org/")));
assert!(!p.allows(&url("https://maliciousexample.com/"))); }
#[test]
fn origins_rejects_host_only_pattern() {
let p = AcceptPaymentPolicy::Origins(vec!["api.example.com".to_string()]);
assert!(!p.allows(&url("https://api.example.com/")));
}
#[test]
fn origins_multiple_patterns() {
let p = AcceptPaymentPolicy::Origins(vec![
"https://app.example.com".to_string(),
"*.trusted.io".to_string(),
]);
assert!(p.allows(&url("https://app.example.com/")));
assert!(p.allows(&url("https://api.trusted.io/")));
assert!(p.allows(&url("https://deep.x.trusted.io/")));
assert!(!p.allows(&url("https://untrusted.com/")));
}
#[test]
fn origins_wildcard_case_insensitive() {
let p = AcceptPaymentPolicy::Origins(vec!["*.Example.COM".to_string()]);
assert!(p.allows(&url("https://API.example.com/")));
}
#[test]
fn exact_origin_normalizes_default_port() {
let p = AcceptPaymentPolicy::Origins(vec!["https://example.com:443".to_string()]);
assert!(p.allows(&url("https://example.com/")));
}
}