Skip to main content

webui_dev_server/
path.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! URL path utilities for dev servers.
5//!
6//! These helpers handle two concerns that every dev server needs:
7//!  1. Strip an application `basePath` (e.g. `/webui/`) from incoming URLs
8//!     on a segment boundary, so `/webui-evil/x` does NOT match `/webui/`.
9//!  2. Resolve the remaining path to a file inside an output directory
10//!     while rejecting traversal, percent-encoded `..`, backslashes, NUL,
11//!     and absolute markers in any segment.
12
13use std::path::{Path, PathBuf};
14
15use percent_encoding::percent_decode_str;
16
17/// Normalize a basePath to either `/` or `/<segments>/` — exactly one
18/// leading and one trailing slash. Used to make matching deterministic.
19#[must_use]
20pub fn normalize_base_path(raw: &str) -> String {
21    let trimmed = raw.trim_matches('/');
22    if trimmed.is_empty() {
23        "/".to_string()
24    } else {
25        format!("/{trimmed}/")
26    }
27}
28
29/// Try to strip `base_path` from a URL path on a segment boundary.
30///
31/// Returns `Some(remainder)` if `path` is exactly `base_path` (with or
32/// without trailing slash) or starts with `base_path`. Returns `None`
33/// otherwise so the caller can decide between redirect and 404.
34///
35/// The remainder never has a leading `/`.
36#[must_use]
37pub fn strip_base_path<'a>(path: &'a str, base_path: &str) -> Option<&'a str> {
38    if base_path == "/" {
39        return Some(path.trim_start_matches('/'));
40    }
41    let without_trailing = base_path.trim_end_matches('/');
42    if path == without_trailing || path == base_path {
43        return Some("");
44    }
45    path.strip_prefix(base_path)
46}
47
48/// Resolve a URL path remainder (already base-stripped) to a filesystem
49/// path inside `out_dir`. Returns `None` for any input that would escape
50/// `out_dir` or that contains components forbidden for security reasons.
51///
52/// Rules:
53///  - Percent-decode each segment.
54///  - Reject any segment equal to `..`, `.`, empty (consecutive `//`),
55///    or containing `\` / NUL.
56///  - Reject Windows drive prefixes / absolute markers in any segment.
57///  - If the original path ends with `/` or is empty, append `index.html`.
58#[must_use]
59pub fn resolve_safe_path(out_dir: &Path, remainder: &str) -> Option<PathBuf> {
60    let trailing_slash = remainder.is_empty() || remainder.ends_with('/');
61    let trimmed = remainder.trim_matches('/');
62
63    let mut buf = out_dir.to_path_buf();
64    if !trimmed.is_empty() {
65        for raw_segment in trimmed.split('/') {
66            let decoded = percent_decode_str(raw_segment).decode_utf8().ok()?;
67            let s = decoded.as_ref();
68            if s.is_empty() || s == "." || s == ".." {
69                return None;
70            }
71            if s.contains('\\') || s.contains('\0') {
72                return None;
73            }
74            if s.starts_with('/') {
75                return None;
76            }
77            // Reject Windows drive-letter prefixes (e.g. "C:") to avoid
78            // `PathBuf::push` jumping to an absolute path on Windows.
79            if has_drive_letter_prefix(s) {
80                return None;
81            }
82            buf.push(s);
83        }
84    }
85
86    if trailing_slash {
87        buf.push("index.html");
88    }
89    Some(buf)
90}
91
92/// Returns true if `s` begins with a Windows drive-letter prefix
93/// (e.g. `C:` or `c:foo`). `PathBuf::push` treats such segments as
94/// absolute, so we reject them defensively even on non-Windows hosts.
95fn has_drive_letter_prefix(s: &str) -> bool {
96    let mut chars = s.chars();
97    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.next(), Some(':'))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn normalize_base_path_handles_root() {
106        assert_eq!(normalize_base_path("/"), "/");
107        assert_eq!(normalize_base_path(""), "/");
108    }
109
110    #[test]
111    fn normalize_base_path_adds_slashes() {
112        assert_eq!(normalize_base_path("webui"), "/webui/");
113        assert_eq!(normalize_base_path("/webui"), "/webui/");
114        assert_eq!(normalize_base_path("/webui/"), "/webui/");
115        assert_eq!(normalize_base_path("a/b"), "/a/b/");
116    }
117
118    #[test]
119    fn strip_base_path_root() {
120        assert_eq!(strip_base_path("/", "/"), Some(""));
121        assert_eq!(strip_base_path("/foo.css", "/"), Some("foo.css"));
122        assert_eq!(strip_base_path("/guide/intro/", "/"), Some("guide/intro/"));
123    }
124
125    #[test]
126    fn strip_base_path_segment_boundary() {
127        assert_eq!(strip_base_path("/webui/", "/webui/"), Some(""));
128        assert_eq!(strip_base_path("/webui", "/webui/"), Some(""));
129        assert_eq!(strip_base_path("/webui/guide/", "/webui/"), Some("guide/"));
130        assert_eq!(
131            strip_base_path("/webui/foo.css", "/webui/"),
132            Some("foo.css")
133        );
134    }
135
136    #[test]
137    fn strip_base_path_rejects_similar_prefix() {
138        assert_eq!(strip_base_path("/webui-evil/x", "/webui/"), None);
139        assert_eq!(strip_base_path("/webuixyz", "/webui/"), None);
140        assert_eq!(strip_base_path("/", "/webui/"), None);
141        assert_eq!(strip_base_path("/other/foo", "/webui/"), None);
142    }
143
144    #[test]
145    fn resolve_safe_path_basic() {
146        let out = Path::new("/tmp/dist");
147        assert_eq!(
148            resolve_safe_path(out, "").as_deref(),
149            Some(Path::new("/tmp/dist/index.html"))
150        );
151        assert_eq!(
152            resolve_safe_path(out, "foo.css").as_deref(),
153            Some(Path::new("/tmp/dist/foo.css"))
154        );
155        assert_eq!(
156            resolve_safe_path(out, "guide/intro/").as_deref(),
157            Some(Path::new("/tmp/dist/guide/intro/index.html"))
158        );
159    }
160
161    #[test]
162    fn resolve_safe_path_rejects_traversal() {
163        let out = Path::new("/tmp/dist");
164        assert_eq!(resolve_safe_path(out, "../etc/passwd"), None);
165        assert_eq!(resolve_safe_path(out, "foo/../../bar"), None);
166        assert_eq!(resolve_safe_path(out, "%2e%2e/etc"), None);
167        assert_eq!(resolve_safe_path(out, "foo/%2e%2e/bar"), None);
168        assert_eq!(resolve_safe_path(out, "foo//bar"), None);
169        assert_eq!(resolve_safe_path(out, "foo\\bar"), None);
170        assert_eq!(resolve_safe_path(out, "./foo"), None);
171    }
172
173    #[test]
174    fn resolve_safe_path_decodes_legal_percent() {
175        let out = Path::new("/tmp/dist");
176        assert_eq!(
177            resolve_safe_path(out, "my%20file.html").as_deref(),
178            Some(Path::new("/tmp/dist/my file.html"))
179        );
180    }
181
182    #[test]
183    fn resolve_safe_path_rejects_drive_letters() {
184        let out = Path::new("/tmp/dist");
185        assert_eq!(resolve_safe_path(out, "C:/Windows/System32"), None);
186        assert_eq!(resolve_safe_path(out, "c:foo"), None);
187        assert_eq!(resolve_safe_path(out, "foo/C:/bar"), None);
188        // Decoded form should also be rejected.
189        assert_eq!(resolve_safe_path(out, "C%3A/win"), None);
190    }
191}