1use wafrift_types::{Method, glob_match};
15
16#[derive(Debug, Clone, Default)]
18pub struct ScopeFilter {
19 only_hosts: Vec<String>,
20 skip_hosts: Vec<String>,
21 only_paths: Vec<String>,
22 skip_paths: Vec<String>,
23 only_methods: Vec<Method>,
24}
25
26impl ScopeFilter {
27 pub fn new(
28 only_hosts: Vec<String>,
29 skip_hosts: Vec<String>,
30 only_paths: Vec<String>,
31 skip_paths: Vec<String>,
32 only_methods: Vec<String>,
33 ) -> Self {
34 Self {
35 only_hosts,
36 skip_hosts,
37 only_paths,
38 skip_paths,
39 only_methods: only_methods
40 .into_iter()
41 .map(|m| Method::from(m.as_str()))
42 .collect(),
43 }
44 }
45
46 #[must_use]
49 pub fn is_empty(&self) -> bool {
50 self.only_hosts.is_empty()
51 && self.skip_hosts.is_empty()
52 && self.only_paths.is_empty()
53 && self.skip_paths.is_empty()
54 && self.only_methods.is_empty()
55 }
56
57 #[must_use]
67 pub fn allows(&self, host: &str, path: &str, method: &Method) -> bool {
68 if !self.only_methods.is_empty() && !self.only_methods.contains(method) {
69 return false;
70 }
71 let host_no_port = strip_port(host);
79 if !self.only_hosts.is_empty()
80 && !self.only_hosts.iter().any(|p| glob_match(p, host_no_port))
81 {
82 return false;
83 }
84 if self.skip_hosts.iter().any(|p| glob_match(p, host_no_port)) {
85 return false;
86 }
87 if !self.only_paths.is_empty() && !self.only_paths.iter().any(|p| glob_match(p, path)) {
88 return false;
89 }
90 if self.skip_paths.iter().any(|p| glob_match(p, path)) {
91 return false;
92 }
93 true
94 }
95}
96
97fn strip_port(host: &str) -> &str {
102 if let Some(stripped) = host.strip_prefix('[') {
103 if let Some(close) = stripped.find(']') {
106 return &host[..close + 2];
107 }
108 return host;
109 }
110 match host.rsplit_once(':') {
112 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
113 _ => host,
114 }
115}
116
117#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn glob_literal_match_is_case_insensitive() {
127 assert!(glob_match("Example.com", "example.COM"));
128 assert!(!glob_match("example.com", "examples.com"));
129 }
130
131 #[test]
132 fn glob_star_matches_subdomains() {
133 assert!(glob_match("*.example.com", "api.example.com"));
134 assert!(glob_match("*.example.com", "deep.api.example.com"));
135 assert!(!glob_match("*.example.com", "example.com"));
136 }
137
138 #[test]
139 fn glob_star_anywhere_in_pattern() {
140 assert!(glob_match("/api/*", "/api/v1/users"));
141 assert!(glob_match("/api/*/users", "/api/v1/users"));
142 assert!(!glob_match("/api/*", "/web/v1"));
143 }
144
145 #[test]
146 fn glob_question_matches_one_char() {
147 assert!(glob_match("v?", "v1"));
148 assert!(!glob_match("v?", "v10"));
149 assert!(!glob_match("v?", "v"));
150 }
151
152 #[test]
153 fn empty_filter_allows_everything() {
154 let f = ScopeFilter::default();
155 assert!(f.is_empty());
156 assert!(f.allows("any.host", "/anything", &Method::from("POST")));
157 }
158
159 #[test]
160 fn only_host_blocks_other_hosts() {
161 let f = ScopeFilter::new(
162 vec!["api.example.com".into()],
163 vec![],
164 vec![],
165 vec![],
166 vec![],
167 );
168 assert!(f.allows("api.example.com", "/x", &Method::from("GET")));
169 assert!(!f.allows("oauth.example.com", "/x", &Method::from("GET")));
170 }
171
172 #[test]
173 fn skip_path_excludes_static_assets() {
174 let f = ScopeFilter::new(
175 vec![],
176 vec![],
177 vec![],
178 vec!["/static/*".into(), "/oauth/*".into(), "/favicon.ico".into()],
179 vec![],
180 );
181 assert!(f.allows("h", "/api/users", &Method::from("GET")));
182 assert!(!f.allows("h", "/static/app.js", &Method::from("GET")));
183 assert!(!f.allows("h", "/oauth/callback", &Method::from("GET")));
184 assert!(!f.allows("h", "/favicon.ico", &Method::from("GET")));
185 }
186
187 #[test]
188 fn only_method_filters_by_verb() {
189 let f = ScopeFilter::new(
190 vec![],
191 vec![],
192 vec![],
193 vec![],
194 vec!["POST".into(), "PUT".into()],
195 );
196 assert!(f.allows("h", "/x", &Method::from("POST")));
197 assert!(f.allows("h", "/x", &Method::from("PUT")));
198 assert!(!f.allows("h", "/x", &Method::from("GET")));
199 }
200
201 #[test]
202 fn skip_host_overrides_only_host() {
203 let f = ScopeFilter::new(
205 vec!["*.example.com".into()],
206 vec!["status.example.com".into()],
207 vec![],
208 vec![],
209 vec![],
210 );
211 assert!(f.allows("api.example.com", "/x", &Method::from("GET")));
212 assert!(!f.allows("status.example.com", "/x", &Method::from("GET")));
213 }
214
215 #[test]
216 fn combined_filters_are_anded() {
217 let f = ScopeFilter::new(
218 vec!["*.example.com".into()],
219 vec![],
220 vec!["/api/*".into()],
221 vec!["/api/health".into()],
222 vec!["GET".into(), "POST".into()],
223 );
224 assert!(f.allows("api.example.com", "/api/users", &Method::from("GET")));
225 assert!(!f.allows("api.example.com", "/api/health", &Method::from("GET")));
226 assert!(!f.allows("api.example.com", "/web/users", &Method::from("GET")));
227 assert!(!f.allows("oauth.elsewhere.com", "/api/x", &Method::from("GET")));
228 assert!(!f.allows("api.example.com", "/api/x", &Method::from("DELETE")));
229 }
230
231 #[test]
232 fn only_host_does_not_match_substring_smuggling() {
233 let f = ScopeFilter::new(vec!["*.example.com".into()], vec![], vec![], vec![], vec![]);
236 assert!(!f.allows("api.example.com.attacker.tld", "/", &Method::from("GET")));
237 }
238
239 #[test]
242 fn strip_port_handles_bare_host() {
243 assert_eq!(strip_port("api.example.com"), "api.example.com");
244 }
245
246 #[test]
247 fn strip_port_removes_port_suffix() {
248 assert_eq!(strip_port("api.example.com:8443"), "api.example.com");
249 assert_eq!(strip_port("api.example.com:80"), "api.example.com");
250 }
251
252 #[test]
253 fn strip_port_leaves_non_numeric_suffix_alone() {
254 assert_eq!(
257 strip_port("api.example.com:notaport"),
258 "api.example.com:notaport"
259 );
260 }
261
262 #[test]
263 fn strip_port_handles_ipv6_literal_with_port() {
264 assert_eq!(strip_port("[::1]:8443"), "[::1]");
265 assert_eq!(strip_port("[2001:db8::1]:443"), "[2001:db8::1]");
266 }
267
268 #[test]
269 fn strip_port_handles_ipv6_literal_without_port() {
270 assert_eq!(strip_port("[::1]"), "[::1]");
271 }
272
273 #[test]
274 fn allows_glob_match_after_port_strip() {
275 let f = ScopeFilter::new(vec!["*.example.com".into()], vec![], vec![], vec![], vec![]);
280 assert!(f.allows("api.example.com:8443", "/", &Method::from("GET")));
281 assert!(f.allows("api.example.com:80", "/", &Method::from("GET")));
282 }
283
284 #[test]
285 fn allows_skip_host_match_after_port_strip() {
286 let f = ScopeFilter::new(
289 vec![],
290 vec![],
291 vec!["admin.internal".into()],
292 vec![],
293 vec![],
294 );
295 assert!(!f.allows("admin.internal:9000", "/", &Method::from("GET")));
296 }
297
298 #[test]
301 fn empty_pattern_only_matches_empty_string() {
302 assert!(glob_match("", ""));
303 assert!(!glob_match("", "a"));
304 assert!(!glob_match("", "abc"));
305 }
306
307 #[test]
308 fn star_pattern_matches_any_string() {
309 assert!(glob_match("*", ""));
310 assert!(glob_match("*", "anything"));
311 assert!(glob_match("*", "a.b.c.d.e"));
312 }
313
314 #[test]
315 fn question_does_not_match_empty() {
316 assert!(!glob_match("?", ""));
318 assert!(glob_match("?", "x"));
319 assert!(!glob_match("?", "xy"));
320 }
321
322 #[test]
323 fn glob_with_no_wildcards_is_exact_case_insensitive_match() {
324 assert!(glob_match("example.com", "EXAMPLE.COM"));
325 assert!(!glob_match("example.com", "example.net"));
326 assert!(!glob_match("example.com", "example.comm"));
327 }
328
329 #[test]
330 fn glob_double_star_acts_as_two_separate_wildcards() {
331 assert!(glob_match("**", "anything"));
334 assert!(glob_match("a**b", "ab"));
335 assert!(glob_match("a**b", "aXXb"));
336 }
337
338 #[test]
352 fn glob_match_benchmark_worst_case_does_not_hang() {
353 let start = std::time::Instant::now();
354 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";
356 let subject = "b".repeat(128);
357 let result = glob_match(pattern, &subject);
358 let elapsed = start.elapsed();
359 assert!(!result, "expected no match on all-b subject");
360 assert!(
361 elapsed.as_millis() < 100,
362 "glob_match took {elapsed:?} — iterative O(|p|·|s|) impl required, not recursive"
363 );
364 }
365}