Skip to main content

wafrift_proxy/
scope.rs

1//! Per-request scope filtering for the proxy.
2//!
3//! Without a scope filter, the proxy evades *every* outbound request a
4//! client makes — login flows, oauth callbacks, static assets, telemetry
5//! beacons. That behaviour is correct for a focused scan but wrong for a
6//! security practitioner who has dropped wafrift-proxy in front of Burp
7//! and is browsing a target normally. Out-of-scope requests are forwarded
8//! verbatim with no evasion, no gene-bank update, no detection logic.
9//!
10//! The matchers support a tiny ASCII glob grammar: `*` matches any run of
11//! characters and `?` matches exactly one. Comparisons are case-
12//! insensitive against the host and path components. No regex deps.
13
14use wafrift_types::{Method, glob_match};
15
16/// Compiled scope predicate evaluated on every proxied request.
17#[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    /// Returns true when no scoping at all is configured — callers can
47    /// skip the filter check entirely.
48    #[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    /// Decide whether a request is in the evasion scope.
58    ///
59    /// Semantics:
60    ///   * `--only-host`/`--only-path`/`--only-method` are inclusive
61    ///     filters: at least one entry must match (an empty list means
62    ///     "no filter").
63    ///   * `--skip-host`/`--skip-path` are exclusive filters: any match
64    ///     drops the request out of scope, evaluated AFTER the inclusive
65    ///     filters.
66    #[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        // Strip an optional port suffix before glob-matching. Hyper /
72        // many HTTP parsers surface the host as `api.example.com:8443`
73        // for non-default ports; pre-fix the glob `*.example.com`
74        // would NOT match because `:8443` is an unexpected suffix.
75        // Out-of-scope requests to non-standard ports could pass the
76        // filter unguarded. IPv6 literals `[::1]:443` are also covered
77        // — split on the LAST `:` keeps the bracketed v6 intact.
78        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
97/// Drop a trailing `:PORT` suffix if present. Bracketed IPv6 literals
98/// (`[::1]`) keep their brackets; only the suffix AFTER the closing
99/// `]` is stripped. For bare hosts (no `:`) the input is returned
100/// unchanged.
101fn strip_port(host: &str) -> &str {
102    if let Some(stripped) = host.strip_prefix('[') {
103        // IPv6 literal: find the closing `]`. The port (if any) is
104        // after that. Return everything up to and including the `]`.
105        if let Some(close) = stripped.find(']') {
106            return &host[..close + 2];
107        }
108        return host;
109    }
110    // IPv4 / DNS: a single `:` separates host and port.
111    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// `glob_match` is re-exported from wafrift-types so there is one
118// canonical O(|p|·|s|) iterative implementation shared with the CLI
119// report filter. See wafrift_types::glob_match for the full doc.
120
121#[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        // `--only-host *.example.com --skip-host status.example.com`
204        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        // Defends against "evil-api.example.com.attacker.tld" sneaking
234        // through "*.example.com" — the glob anchors the whole string.
235        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    // ── strip_port + allows() with :port suffix ───────────────
240
241    #[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        // `:something-not-a-port` is part of the host (defensive
255        // against malformed input — keep it visible).
256        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        // Regression: pre-fix `*.example.com` would NOT match
276        // `api.example.com:8443` and the request would slip past
277        // the scope filter unguarded. The port-strip makes the
278        // match work as the operator expects.
279        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        // Skip-host also benefits — an attacker can't bypass a
287        // skip rule by adding a port.
288        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    // -- §12 boundary and edge-case tests ----------------------------------
299
300    #[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        // `?` matches exactly ONE character -- empty string must fail.
317        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        // `**` is treated as two adjacent stars in this glob engine.
332        // The result should match at least as broadly as a single `*`.
333        assert!(glob_match("**", "anything"));
334        assert!(glob_match("a**b", "ab"));
335        assert!(glob_match("a**b", "aXXb"));
336    }
337
338    /// ReDoS guard — attacker-controlled host/path as subject.
339    ///
340    /// The OLD recursive impl was O(|s|^k) with k wildcards. A pattern
341    /// like `*a*a*a*a*a*a` (6 wildcards) against `bbbbbbbbbbbbbbbb`
342    /// (16 bytes) would require ~16^6 ≈ 16M recursive calls. With 30
343    /// wildcards and a 128-byte non-matching subject that is 128^30 —
344    /// effectively infinite. The iterative two-pointer algorithm is
345    /// O(|p|·|s|) = O(30×128) = 3840 steps.
346    ///
347    /// This test uses 30 `*a` pairs (30 wildcards) and a 128-byte
348    /// all-`b` subject — the worst-case combination for the old impl —
349    /// and asserts completion in under 100 ms (the iterative impl
350    /// completes in microseconds on any modern CPU).
351    #[test]
352    fn glob_match_benchmark_worst_case_does_not_hang() {
353        let start = std::time::Instant::now();
354        // 30 interleaved wildcards; subject is 128 non-matching bytes.
355        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}