Skip to main content

guard_core_engine/
path_matching.rs

1//! The `exclude_paths` matcher: the reference
2//! `core/validation/path_matching.py`.
3//!
4//! A request path is normalized before it is compared, so an encoded or
5//! dot-segmented spelling cannot slip past (or into) an exclusion:
6//!
7//! 1. percent-decode runs of `%XX` as UTF-8, up to four rounds; an invalid
8//!    UTF-8 run, or a `%XX` still left after the fourth round, makes the
9//!    path unmatchable (never excluded);
10//! 2. fold `\` to `/`, drop empty and `.` segments, resolve `..` against
11//!    the segments seen so far, and treat a `..;param` / `.;param` segment
12//!    as its dot form.
13//!
14//! An exclusion matches the normalized path when it equals it or is a
15//! `/`-bounded prefix of it (`/static` excludes `/static` and
16//! `/static/app.js`, never `/staticx`); an exclusion of `/` matches every
17//! path. Exclusions normalize the same way, and an exclusion that cannot
18//! normalize is dropped.
19//!
20//! # Example
21//!
22//! ```
23//! use guard_core_engine::path_matching::PathExclusions;
24//!
25//! let exclusions = PathExclusions::new(["/static", "/docs"]);
26//! assert!(exclusions.is_excluded("/static/app.js"));
27//! assert!(exclusions.is_excluded("/static/../static/app.js"));
28//! assert!(!exclusions.is_excluded("/static/../admin"));
29//! assert!(!exclusions.is_excluded("/staticx"));
30//! ```
31
32/// The most percent-decode rounds the reference runs.
33const MAX_DECODE_ROUNDS: usize = 4;
34
35/// The value of one ASCII hex digit.
36const fn hex_value(byte: u8) -> Option<u8> {
37    match byte {
38        b'0'..=b'9' => Some(byte - b'0'),
39        b'a'..=b'f' => Some(byte - b'a' + 10),
40        b'A'..=b'F' => Some(byte - b'A' + 10),
41        _ => None,
42    }
43}
44
45/// The byte a `%XX` escape at the start of `bytes` encodes.
46fn escape_at(bytes: &[u8]) -> Option<u8> {
47    match bytes {
48        [b'%', high, low, ..] => Some(hex_value(*high)? << 4 | hex_value(*low)?),
49        _ => None,
50    }
51}
52
53/// One round: every maximal run of `%XX` is decoded as UTF-8 (the
54/// reference `(?:%[0-9A-Fa-f]{2})+` substitution). `None` when a run is not
55/// valid UTF-8.
56fn decode_percent_once(value: &str) -> Option<String> {
57    let bytes = value.as_bytes();
58    let mut out = String::with_capacity(value.len());
59    let mut index = 0;
60    while index < bytes.len() {
61        if escape_at(&bytes[index..]).is_none() {
62            // Copy one whole character (`%` and hex digits are ASCII, so a
63            // non-escape position is always a char boundary).
64            let ch = value[index..].chars().next()?;
65            out.push(ch);
66            index += ch.len_utf8();
67            continue;
68        }
69        let mut run = Vec::new();
70        while let Some(byte) = escape_at(&bytes[index..]) {
71            run.push(byte);
72            index += 3;
73        }
74        out.push_str(&String::from_utf8(run).ok()?);
75    }
76    Some(out)
77}
78
79/// Whether any `%XX` escape is left in `value`.
80fn has_escape(value: &str) -> bool {
81    let bytes = value.as_bytes();
82    (0..bytes.len()).any(|index| escape_at(&bytes[index..]).is_some())
83}
84
85/// `_decode_percent_recursive`.
86fn decode_percent_recursive(raw: &str) -> Option<String> {
87    let mut decoded = raw.to_owned();
88    for _ in 0..MAX_DECODE_ROUNDS {
89        decoded = decode_percent_once(&decoded)?;
90    }
91    (!has_escape(&decoded)).then_some(decoded)
92}
93
94/// `_fold_dot_segment_params`: `..;x` and `.;x` fold to their dot form.
95fn fold_dot_segment_params(segment: &str) -> &str {
96    let base = segment.split(';').next().unwrap_or(segment);
97    if base == "." || base == ".." {
98        base
99    } else {
100        segment
101    }
102}
103
104/// `_collapse_dot_segments`.
105fn collapse_dot_segments(decoded: &str) -> String {
106    let mut segments: Vec<&str> = Vec::new();
107    for raw_segment in decoded.split(['/', '\\']) {
108        match fold_dot_segment_params(raw_segment) {
109            "" | "." => {}
110            ".." => {
111                segments.pop();
112            }
113            segment => segments.push(segment),
114        }
115    }
116    format!("/{}", segments.join("/"))
117}
118
119/// `normalize_url_path`: the comparable form of a raw request path, `None`
120/// when it cannot be decoded safely.
121#[must_use]
122pub fn normalize_url_path(raw_path: &str) -> Option<String> {
123    decode_percent_recursive(raw_path).map(|decoded| collapse_dot_segments(&decoded))
124}
125
126/// `normalize_exclude_paths`: the entries that normalize, in order.
127#[must_use]
128pub fn normalize_exclude_paths<I, S>(exclude_paths: I) -> Vec<String>
129where
130    I: IntoIterator<Item = S>,
131    S: AsRef<str>,
132{
133    exclude_paths
134        .into_iter()
135        .filter_map(|entry| normalize_url_path(entry.as_ref()))
136        .collect()
137}
138
139/// `_is_subtree_or_equal`.
140fn is_subtree_or_equal(path: &str, excluded: &str) -> bool {
141    excluded == "/"
142        || path == excluded
143        || path
144            .strip_prefix(excluded)
145            .is_some_and(|rest| rest.starts_with('/'))
146}
147
148/// `path_matches_exclusions`: the already-normalized path against
149/// already-normalized exclusions.
150#[must_use]
151pub fn path_matches_exclusions(normalized_path: &str, normalized_exclusions: &[String]) -> bool {
152    normalized_exclusions
153        .iter()
154        .any(|excluded| is_subtree_or_equal(normalized_path, excluded))
155}
156
157/// `path_is_excluded`: normalize both sides and match.
158#[must_use]
159pub fn path_is_excluded<S: AsRef<str>>(url_path: &str, exclude_paths: &[S]) -> bool {
160    normalize_url_path(url_path)
161        .is_some_and(|path| path_matches_exclusions(&path, &normalize_exclude_paths(exclude_paths)))
162}
163
164/// The configured exclusions, normalized once.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct PathExclusions {
167    normalized: Vec<String>,
168}
169
170impl PathExclusions {
171    /// Normalize `exclude_paths` once.
172    pub fn new<I, S>(exclude_paths: I) -> Self
173    where
174        I: IntoIterator<Item = S>,
175        S: AsRef<str>,
176    {
177        Self {
178            normalized: normalize_exclude_paths(exclude_paths),
179        }
180    }
181
182    /// The normalized exclusions.
183    #[must_use]
184    pub fn normalized(&self) -> &[String] {
185        &self.normalized
186    }
187
188    /// Whether the raw request path is excluded.
189    #[must_use]
190    pub fn is_excluded(&self, url_path: &str) -> bool {
191        normalize_url_path(url_path)
192            .is_some_and(|path| path_matches_exclusions(&path, &self.normalized))
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    const DEFAULTS: [&str; 6] = [
201        "/docs",
202        "/redoc",
203        "/openapi.json",
204        "/openapi.yaml",
205        "/favicon.ico",
206        "/static",
207    ];
208
209    /// `(raw, normalized, excluded by the defaults, excluded by "/",
210    /// excluded by ["/static/", "/a/./b/../c"], excluded by "/%73tatic")`,
211    /// recorded from the reference `path_matching.py`.
212    #[allow(clippy::type_complexity)]
213    const REFERENCE: &[(&str, Option<&str>, bool, bool, bool, bool)] = &[
214        (
215            "/static/app.js",
216            Some("/static/app.js"),
217            true,
218            true,
219            true,
220            true,
221        ),
222        ("/static", Some("/static"), true, true, true, true),
223        ("/static/", Some("/static"), true, true, true, true),
224        (
225            "/static/../admin",
226            Some("/admin"),
227            false,
228            true,
229            false,
230            false,
231        ),
232        ("/%2e%2e/admin", Some("/admin"), false, true, false, false),
233        (
234            "/static/%2e%2e/admin",
235            Some("/admin"),
236            false,
237            true,
238            false,
239            false,
240        ),
241        ("/docs", Some("/docs"), true, true, false, false),
242        ("/docs2", Some("/docs2"), false, true, false, false),
243        ("/docsx/y", Some("/docsx/y"), false, true, false, false),
244        (
245            "/STATIC/app.js",
246            Some("/STATIC/app.js"),
247            false,
248            true,
249            false,
250            false,
251        ),
252        ("/a//b/./c", Some("/a/b/c"), false, true, false, false),
253        ("/a/b/..", Some("/a"), false, true, false, false),
254        ("/a;x=1/../b", Some("/b"), false, true, false, false),
255        ("/%252e%252e/x", Some("/x"), false, true, false, false),
256        ("/%2525252e", Some("/"), false, true, false, false),
257        ("/%ZZ", Some("/%ZZ"), false, true, false, false),
258        ("/%ff", None, false, false, false, false),
259        (
260            "/%e2%82%ac/x",
261            Some("/\u{20ac}/x"),
262            false,
263            true,
264            false,
265            false,
266        ),
267        ("/a\\b", Some("/a/b"), false, true, false, false),
268        ("", Some("/"), false, true, false, false),
269        ("/", Some("/"), false, true, false, false),
270        ("//static//x", Some("/static/x"), true, true, true, true),
271        (
272            "/static;a=b/x",
273            Some("/static;a=b/x"),
274            false,
275            true,
276            false,
277            false,
278        ),
279        ("/%00", Some("/\u{0}"), false, true, false, false),
280        ("/%2F..%2Fadmin", Some("/admin"), false, true, false, false),
281        ("/%252fstatic", Some("/static"), true, true, true, true),
282        ("/x/..;/static/y", Some("/static/y"), true, true, true, true),
283        ("/static/..;/y", Some("/y"), false, true, false, false),
284        ("/%5c..%5cstatic", Some("/static"), true, true, true, true),
285    ];
286
287    #[test]
288    fn normalization_and_matching_follow_the_reference_table() {
289        for (raw, normalized, defaults, root, trailing, encoded) in REFERENCE {
290            assert_eq!(
291                normalize_url_path(raw).as_deref(),
292                *normalized,
293                "normalize {raw:?}"
294            );
295            assert_eq!(
296                path_is_excluded(raw, &DEFAULTS),
297                *defaults,
298                "defaults {raw:?}"
299            );
300            assert_eq!(path_is_excluded(raw, &["/"]), *root, "root {raw:?}");
301            assert_eq!(
302                path_is_excluded(raw, &["/static/", "/a/./b/../c"]),
303                *trailing,
304                "trailing {raw:?}"
305            );
306            assert_eq!(
307                path_is_excluded(raw, &["/%73tatic"]),
308                *encoded,
309                "encoded {raw:?}"
310            );
311        }
312    }
313
314    #[test]
315    fn exclusions_normalize_and_drop_the_undecodable() {
316        assert_eq!(
317            normalize_exclude_paths(["/a/", "//b", "/%ff", "", "c/../d"]),
318            ["/a", "/b", "/", "/d"]
319        );
320    }
321
322    #[test]
323    fn the_default_static_exclusion_covers_its_subtree() {
324        let exclusions = PathExclusions::new(DEFAULTS);
325        assert!(exclusions.is_excluded("/static/app.js"));
326        assert!(!exclusions.is_excluded("/staticx/app.js"));
327        assert!(!exclusions.is_excluded("/%ff"));
328        assert_eq!(exclusions.normalized().len(), DEFAULTS.len());
329    }
330}