Skip to main content

openmoat_core/
route.rs

1//! Method and path rules for HTTP requests (ADR-043): which requests a
2//! brokered secret's `allow:` rules let receive its value, and the request path
3//! in the form they are matched against. Shared, so that other request rules
4//! (ADR-062, proposed) match paths the same way.
5//!
6//! A rule is `METHOD /path`: the method in capitals, compared exactly, and a
7//! path of `/`-separated segments, each a literal, `*` (exactly one non-empty
8//! segment) or `**` (any number of segments, none included). The query is not
9//! matched.
10//!
11//! The path a request names is matched as the server reads it: each segment
12//! percent-decoded once. A path the server could read as another one is
13//! refused instead of matched ([`PathRefusal`]): a `.` or `..` segment in any
14//! spelling, an encoded `/` or `\`, a second layer of encoding, a `;` (matrix
15//! parameters, Tomcat's `..;`), an empty segment, a byte outside visible
16//! ASCII. A request that asks the server to run another method than its own
17//! (a [`METHOD_OVERRIDE_HEADERS`] header, a `_method` query parameter) is
18//! refused too.
19
20use std::fmt;
21
22/// Most rules one secret may list, and most segments one rule may have: the
23/// match costs at most rules × segments × request segments steps.
24pub const MAX_ROUTES: usize = 64;
25const MAX_SEGMENTS: usize = 64;
26
27/// Headers some frameworks read as the request's real method. A request that
28/// carries one is refused for a secret with rules: the server would run a
29/// method the rules never saw.
30pub const METHOD_OVERRIDE_HEADERS: &[&str] = &[
31    "x-http-method-override",
32    "x-http-method",
33    "x-method-override",
34];
35
36/// Methods no rule may name.
37const ECHOING: &[&str] = &["TRACE", "TRACK", "CONNECT"];
38
39/// The query parameter Rails, Laravel and others read as the real method.
40const METHOD_OVERRIDE_PARAMETER: &str = "_method";
41
42/// One `METHOD /path` rule.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Route {
45    method: String,
46    segments: Vec<Segment>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50enum Segment {
51    Literal(String),
52    /// `*`: exactly one non-empty segment.
53    One,
54    /// `**`: zero or more segments.
55    Any,
56}
57
58impl Route {
59    /// Parse `METHOD /path`; the error says what is wrong.
60    pub fn parse(text: &str) -> Result<Self, String> {
61        let (method, path) = text
62            .split_once(' ')
63            .ok_or_else(|| format!("rule `{text}` must be `METHOD /path`"))?;
64        if method.is_empty() || !method.bytes().all(|b| b.is_ascii_uppercase()) {
65            return Err(format!(
66                "rule `{text}`: the method must be capital letters (`GET`, `POST`)"
67            ));
68        }
69        // The answer to `TRACE` is the request as the server got it, value
70        // included; `CONNECT` opens a tunnel nothing reads.
71        if ECHOING.contains(&method) {
72            return Err(format!(
73                "rule `{text}`: {method} is never given a secret: the server would send it back \
74                 or the proxy could not read what follows"
75            ));
76        }
77        let Some(rest) = path.strip_prefix('/') else {
78            return Err(format!("rule `{text}`: the path must start with `/`"));
79        };
80        let mut segments = Vec::new();
81        let parts: Vec<&str> = if rest.is_empty() {
82            Vec::new()
83        } else {
84            rest.split('/').collect()
85        };
86        for (n, part) in parts.iter().enumerate() {
87            let last = n + 1 == parts.len();
88            segments.push(match *part {
89                "*" => Segment::One,
90                "**" => Segment::Any,
91                // A rule ending in `/` names the path with its trailing slash.
92                "" if last => Segment::Literal(String::new()),
93                "" => return Err(format!("rule `{text}`: the path has an empty segment")),
94                "." | ".." => return Err(format!("rule `{text}`: `.` and `..` are not segments")),
95                literal if literal.bytes().all(is_literal_byte) => {
96                    Segment::Literal(literal.to_owned())
97                }
98                _ => {
99                    return Err(format!(
100                        "rule `{text}`: a segment is a literal of letters, digits and \
101                         `-._~!$&'()+,=:@`, `*` or `**`"
102                    ));
103                }
104            });
105        }
106        if segments.len() > MAX_SEGMENTS {
107            return Err(format!(
108                "rule `{text}` has more than {MAX_SEGMENTS} segments"
109            ));
110        }
111        Ok(Self {
112            method: method.to_owned(),
113            segments,
114        })
115    }
116
117    /// Whether a `method` request for `path` matches this rule.
118    #[must_use]
119    pub fn matches(&self, method: &str, path: &RequestPath) -> bool {
120        self.method == method && matches(&self.segments, &path.segments)
121    }
122}
123
124/// Whether `rule` matches `path`: `matched[j]` is true when the first `i`
125/// rule segments match the first `j` path segments, one row per rule segment.
126fn matches(rule: &[Segment], path: &[String]) -> bool {
127    let mut matched = vec![false; path.len() + 1];
128    matched[0] = true;
129    for segment in rule {
130        let mut next = vec![false; path.len() + 1];
131        for j in 0..=path.len() {
132            next[j] = match segment {
133                Segment::Any => matched[j] || (j > 0 && next[j - 1]),
134                Segment::One => j > 0 && matched[j - 1] && !path[j - 1].is_empty(),
135                Segment::Literal(literal) => j > 0 && matched[j - 1] && path[j - 1] == *literal,
136            };
137        }
138        matched = next;
139    }
140    matched[path.len()]
141}
142
143/// RFC 3986 `pchar` without `%` and `;`: a rule's literal is written decoded.
144fn is_literal_byte(b: u8) -> bool {
145    b.is_ascii_alphanumeric() || b"-._~!$&'()+,=:@".contains(&b)
146}
147
148/// The path of a request, decoded segment by segment, and whether its query
149/// names a method override.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct RequestPath {
152    segments: Vec<String>,
153    overrides_method: bool,
154}
155
156/// Why a request's path is refused rather than matched.
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum PathRefusal {
160    /// Not an origin-form path (`/…`): absolute form, `*`, or empty.
161    NotOriginForm,
162    /// A byte outside visible ASCII, a `\`, a `#`, or a malformed `%XX`.
163    Malformed,
164    /// A segment that is, or decodes to, `.` or `..`.
165    DotSegment,
166    /// A segment that decodes to `/`, `\`, `%`, `;`, a control byte or
167    /// invalid UTF-8, or holds a `;`.
168    AmbiguousSegment,
169    /// `//`: an empty segment before the end.
170    EmptySegment,
171}
172
173impl fmt::Display for PathRefusal {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.write_str(match self {
176            Self::NotOriginForm => "the request target is not a path starting with `/`",
177            Self::Malformed => {
178                "the path holds a byte outside visible ASCII, a `\\`, a `#` or a malformed `%` \
179                 escape"
180            }
181            Self::DotSegment => "the path holds a `.` or `..` segment, in some spelling",
182            Self::AmbiguousSegment => {
183                "a path segment holds `;` or decodes to `/`, `\\`, `%`, `;` or a control byte"
184            }
185            Self::EmptySegment => "the path holds an empty segment (`//`)",
186        })
187    }
188}
189
190impl RequestPath {
191    /// The path of `target`, a request line's target, as the server would read
192    /// it; refused when the server could read it as another path.
193    pub fn parse(target: &str) -> Result<Self, PathRefusal> {
194        let Some(rest) = target.strip_prefix('/') else {
195            return Err(PathRefusal::NotOriginForm);
196        };
197        if !target.bytes().all(|b| b.is_ascii_graphic()) || target.contains(['\\', '#']) {
198            return Err(PathRefusal::Malformed);
199        }
200        let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
201        let parts: Vec<&str> = if path.is_empty() {
202            Vec::new()
203        } else {
204            path.split('/').collect()
205        };
206        let mut segments = Vec::with_capacity(parts.len());
207        for (n, part) in parts.iter().enumerate() {
208            if part.is_empty() && n + 1 < parts.len() {
209                return Err(PathRefusal::EmptySegment);
210            }
211            if part.contains(';') {
212                return Err(PathRefusal::AmbiguousSegment);
213            }
214            let decoded = decode(part).ok_or(PathRefusal::Malformed)?;
215            let decoded = String::from_utf8(decoded).map_err(|_| PathRefusal::AmbiguousSegment)?;
216            if decoded == "." || decoded == ".." {
217                return Err(PathRefusal::DotSegment);
218            }
219            if decoded
220                .chars()
221                .any(|c| c.is_control() || matches!(c, '/' | '\\' | '%' | ';'))
222            {
223                return Err(PathRefusal::AmbiguousSegment);
224            }
225            segments.push(decoded);
226        }
227        let overrides_method = query.split('&').any(|pair| {
228            let name = pair.split_once('=').map_or(pair, |(name, _)| name);
229            decode(name)
230                .is_none_or(|n| n.eq_ignore_ascii_case(METHOD_OVERRIDE_PARAMETER.as_bytes()))
231        });
232        Ok(Self {
233            segments,
234            overrides_method,
235        })
236    }
237
238    /// Whether the query names a `_method` parameter (in any letter case or
239    /// encoding), or a parameter name that does not decode.
240    #[must_use]
241    pub fn overrides_method(&self) -> bool {
242        self.overrides_method
243    }
244}
245
246/// `text` with each `%XX` replaced by its byte; `None` for a malformed escape.
247fn decode(text: &str) -> Option<Vec<u8>> {
248    let bytes = text.as_bytes();
249    let mut out = Vec::with_capacity(bytes.len());
250    let mut i = 0;
251    while i < bytes.len() {
252        if bytes[i] == b'%' {
253            // Two hex digits exactly: `from_str_radix` would also take `+1`.
254            let hex = bytes
255                .get(i + 1..i + 3)
256                .filter(|h| h.iter().all(u8::is_ascii_hexdigit))?;
257            out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
258            i += 3;
259        } else {
260            out.push(bytes[i]);
261            i += 1;
262        }
263    }
264    Some(out)
265}
266
267#[cfg(test)]
268mod tests;