use wafrift_types::{Method, glob_match};
#[derive(Debug, Clone, Default)]
pub struct ScopeFilter {
only_hosts: Vec<String>,
skip_hosts: Vec<String>,
only_paths: Vec<String>,
skip_paths: Vec<String>,
only_methods: Vec<Method>,
}
impl ScopeFilter {
pub fn new(
only_hosts: Vec<String>,
skip_hosts: Vec<String>,
only_paths: Vec<String>,
skip_paths: Vec<String>,
only_methods: Vec<String>,
) -> Self {
Self {
only_hosts,
skip_hosts,
only_paths,
skip_paths,
only_methods: only_methods
.into_iter()
.map(|m| Method::from(m.as_str()))
.collect(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.only_hosts.is_empty()
&& self.skip_hosts.is_empty()
&& self.only_paths.is_empty()
&& self.skip_paths.is_empty()
&& self.only_methods.is_empty()
}
#[must_use]
pub fn allows(&self, host: &str, path: &str, method: &Method) -> bool {
if !self.only_methods.is_empty() && !self.only_methods.contains(method) {
return false;
}
let host_no_port = strip_port(host);
if !self.only_hosts.is_empty()
&& !self.only_hosts.iter().any(|p| glob_match(p, host_no_port))
{
return false;
}
if self.skip_hosts.iter().any(|p| glob_match(p, host_no_port)) {
return false;
}
if !self.only_paths.is_empty() && !self.only_paths.iter().any(|p| glob_match(p, path)) {
return false;
}
if self.skip_paths.iter().any(|p| glob_match(p, path)) {
return false;
}
true
}
}
fn strip_port(host: &str) -> &str {
if let Some(stripped) = host.strip_prefix('[') {
if let Some(close) = stripped.find(']') {
return &host[..close + 2];
}
return host;
}
match host.rsplit_once(':') {
Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
_ => host,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_literal_match_is_case_insensitive() {
assert!(glob_match("Example.com", "example.COM"));
assert!(!glob_match("example.com", "examples.com"));
}
#[test]
fn glob_star_matches_subdomains() {
assert!(glob_match("*.example.com", "api.example.com"));
assert!(glob_match("*.example.com", "deep.api.example.com"));
assert!(!glob_match("*.example.com", "example.com"));
}
#[test]
fn glob_star_anywhere_in_pattern() {
assert!(glob_match("/api/*", "/api/v1/users"));
assert!(glob_match("/api/*/users", "/api/v1/users"));
assert!(!glob_match("/api/*", "/web/v1"));
}
#[test]
fn glob_question_matches_one_char() {
assert!(glob_match("v?", "v1"));
assert!(!glob_match("v?", "v10"));
assert!(!glob_match("v?", "v"));
}
#[test]
fn empty_filter_allows_everything() {
let f = ScopeFilter::default();
assert!(f.is_empty());
assert!(f.allows("any.host", "/anything", &Method::from("POST")));
}
#[test]
fn only_host_blocks_other_hosts() {
let f = ScopeFilter::new(
vec!["api.example.com".into()],
vec![],
vec![],
vec![],
vec![],
);
assert!(f.allows("api.example.com", "/x", &Method::from("GET")));
assert!(!f.allows("oauth.example.com", "/x", &Method::from("GET")));
}
#[test]
fn skip_path_excludes_static_assets() {
let f = ScopeFilter::new(
vec![],
vec![],
vec![],
vec!["/static/*".into(), "/oauth/*".into(), "/favicon.ico".into()],
vec![],
);
assert!(f.allows("h", "/api/users", &Method::from("GET")));
assert!(!f.allows("h", "/static/app.js", &Method::from("GET")));
assert!(!f.allows("h", "/oauth/callback", &Method::from("GET")));
assert!(!f.allows("h", "/favicon.ico", &Method::from("GET")));
}
#[test]
fn only_method_filters_by_verb() {
let f = ScopeFilter::new(
vec![],
vec![],
vec![],
vec![],
vec!["POST".into(), "PUT".into()],
);
assert!(f.allows("h", "/x", &Method::from("POST")));
assert!(f.allows("h", "/x", &Method::from("PUT")));
assert!(!f.allows("h", "/x", &Method::from("GET")));
}
#[test]
fn skip_host_overrides_only_host() {
let f = ScopeFilter::new(
vec!["*.example.com".into()],
vec!["status.example.com".into()],
vec![],
vec![],
vec![],
);
assert!(f.allows("api.example.com", "/x", &Method::from("GET")));
assert!(!f.allows("status.example.com", "/x", &Method::from("GET")));
}
#[test]
fn combined_filters_are_anded() {
let f = ScopeFilter::new(
vec!["*.example.com".into()],
vec![],
vec!["/api/*".into()],
vec!["/api/health".into()],
vec!["GET".into(), "POST".into()],
);
assert!(f.allows("api.example.com", "/api/users", &Method::from("GET")));
assert!(!f.allows("api.example.com", "/api/health", &Method::from("GET")));
assert!(!f.allows("api.example.com", "/web/users", &Method::from("GET")));
assert!(!f.allows("oauth.elsewhere.com", "/api/x", &Method::from("GET")));
assert!(!f.allows("api.example.com", "/api/x", &Method::from("DELETE")));
}
#[test]
fn only_host_does_not_match_substring_smuggling() {
let f = ScopeFilter::new(vec!["*.example.com".into()], vec![], vec![], vec![], vec![]);
assert!(!f.allows("api.example.com.attacker.tld", "/", &Method::from("GET")));
}
#[test]
fn strip_port_handles_bare_host() {
assert_eq!(strip_port("api.example.com"), "api.example.com");
}
#[test]
fn strip_port_removes_port_suffix() {
assert_eq!(strip_port("api.example.com:8443"), "api.example.com");
assert_eq!(strip_port("api.example.com:80"), "api.example.com");
}
#[test]
fn strip_port_leaves_non_numeric_suffix_alone() {
assert_eq!(
strip_port("api.example.com:notaport"),
"api.example.com:notaport"
);
}
#[test]
fn strip_port_handles_ipv6_literal_with_port() {
assert_eq!(strip_port("[::1]:8443"), "[::1]");
assert_eq!(strip_port("[2001:db8::1]:443"), "[2001:db8::1]");
}
#[test]
fn strip_port_handles_ipv6_literal_without_port() {
assert_eq!(strip_port("[::1]"), "[::1]");
}
#[test]
fn allows_glob_match_after_port_strip() {
let f = ScopeFilter::new(vec!["*.example.com".into()], vec![], vec![], vec![], vec![]);
assert!(f.allows("api.example.com:8443", "/", &Method::from("GET")));
assert!(f.allows("api.example.com:80", "/", &Method::from("GET")));
}
#[test]
fn allows_skip_host_match_after_port_strip() {
let f = ScopeFilter::new(
vec![],
vec![],
vec!["admin.internal".into()],
vec![],
vec![],
);
assert!(!f.allows("admin.internal:9000", "/", &Method::from("GET")));
}
#[test]
fn empty_pattern_only_matches_empty_string() {
assert!(glob_match("", ""));
assert!(!glob_match("", "a"));
assert!(!glob_match("", "abc"));
}
#[test]
fn star_pattern_matches_any_string() {
assert!(glob_match("*", ""));
assert!(glob_match("*", "anything"));
assert!(glob_match("*", "a.b.c.d.e"));
}
#[test]
fn question_does_not_match_empty() {
assert!(!glob_match("?", ""));
assert!(glob_match("?", "x"));
assert!(!glob_match("?", "xy"));
}
#[test]
fn glob_with_no_wildcards_is_exact_case_insensitive_match() {
assert!(glob_match("example.com", "EXAMPLE.COM"));
assert!(!glob_match("example.com", "example.net"));
assert!(!glob_match("example.com", "example.comm"));
}
#[test]
fn glob_double_star_acts_as_two_separate_wildcards() {
assert!(glob_match("**", "anything"));
assert!(glob_match("a**b", "ab"));
assert!(glob_match("a**b", "aXXb"));
}
#[test]
fn glob_match_benchmark_worst_case_does_not_hang() {
let start = std::time::Instant::now();
let pattern = "*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a";
let subject = "b".repeat(128);
let result = glob_match(pattern, &subject);
let elapsed = start.elapsed();
assert!(!result, "expected no match on all-b subject");
assert!(
elapsed.as_millis() < 100,
"glob_match took {elapsed:?} — iterative O(|p|·|s|) impl required, not recursive"
);
}
}