Skip to main content

openmoat_core/secret/
route.rs

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