Skip to main content

sandlock_core/
http.rs

1use serde::{Deserialize, Serialize};
2
3use crate::error::SandboxError;
4
5/// An HTTP access control rule.
6#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
7pub struct HttpRule {
8    pub method: String,
9    pub host: String,
10    pub path: String,
11}
12
13impl HttpRule {
14    /// Parse a rule from "METHOD host/path" format.
15    ///
16    /// Examples:
17    /// - `"GET api.example.com/v1/*"` -> method="GET", host="api.example.com", path="/v1/*"
18    /// - `"* */admin/*"` -> method="*", host="*", path="/admin/*"
19    /// - `"GET example.com"` -> method="GET", host="example.com", path="/*"
20    pub fn parse(s: &str) -> Result<Self, SandboxError> {
21        let s = s.trim();
22        let (method, rest) = s
23            .split_once(char::is_whitespace)
24            .ok_or_else(|| SandboxError::Invalid(format!("invalid http rule: {}", s)))?;
25        let rest = rest.trim();
26        if rest.is_empty() {
27            return Err(SandboxError::Invalid(format!("invalid http rule: {}", s)));
28        }
29
30        let (host, path) = if let Some(pos) = rest.find('/') {
31            let (h, p) = rest.split_at(pos);
32            // Normalize the rule path, but preserve trailing * for glob matching.
33            let has_wildcard = p.ends_with('*');
34            let mut normalized = normalize_path(p);
35            if has_wildcard && !normalized.ends_with('*') {
36                normalized.push('*');
37            }
38            (h.to_string(), normalized)
39        } else {
40            (rest.to_string(), "/*".to_string())
41        };
42
43        Ok(HttpRule {
44            method: method.to_uppercase(),
45            host,
46            path,
47        })
48    }
49
50    /// Check whether this rule matches the given request parameters.
51    /// The request path is normalized before matching to prevent bypasses
52    /// via `//`, `/../`, `/.`, or percent-encoding.
53    pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
54        // Method match
55        if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
56            return false;
57        }
58        // Host match
59        if self.host != "*" && !self.host.eq_ignore_ascii_case(host) {
60            return false;
61        }
62        // Path match: normalize to prevent encoding/traversal bypasses.
63        let normalized = normalize_path(path);
64        prefix_or_exact_match(&self.path, &normalized)
65    }
66}
67
68/// Normalize an HTTP path to prevent ACL bypasses via encoding tricks.
69///
70/// - Decodes percent-encoded characters (e.g. `%2F` -> `/`, `%61` -> `a`)
71/// - Collapses duplicate slashes (`//` -> `/`)
72/// - Resolves `.` and `..` segments
73/// - Ensures the path starts with `/`
74pub fn normalize_path(path: &str) -> String {
75    // 1. Percent-decode
76    let mut decoded = String::with_capacity(path.len());
77    let mut chars = path.bytes();
78    while let Some(b) = chars.next() {
79        if b == b'%' {
80            let hi = chars.next();
81            let lo = chars.next();
82            if let (Some(h), Some(l)) = (hi, lo) {
83                let hex = [h, l];
84                if let Ok(s) = std::str::from_utf8(&hex) {
85                    if let Ok(val) = u8::from_str_radix(s, 16) {
86                        decoded.push(val as char);
87                        continue;
88                    }
89                }
90                // Malformed percent encoding: keep as-is.
91                decoded.push(b as char);
92                decoded.push(h as char);
93                decoded.push(l as char);
94            } else {
95                decoded.push(b as char);
96            }
97        } else {
98            decoded.push(b as char);
99        }
100    }
101
102    // 2. Split into segments, resolve . and .., skip empty segments (collapses //)
103    let mut segments: Vec<&str> = Vec::new();
104    for seg in decoded.split('/') {
105        match seg {
106            "" | "." => {}
107            ".." => {
108                segments.pop();
109            }
110            s => segments.push(s),
111        }
112    }
113
114    // 3. Reconstruct with leading /
115    let mut result = String::with_capacity(decoded.len());
116    result.push('/');
117    result.push_str(&segments.join("/"));
118    result
119}
120
121/// Simple prefix or exact matching for paths. Supports trailing `*` as a prefix match.
122///
123/// Only supports:
124/// - `"/*"` or `"*"` matches everything
125/// - `"/v1/*"` matches "/v1/foo", "/v1/foo/bar" (prefix match)
126/// - `"/v1/models"` matches exactly "/v1/models" (exact match)
127///
128/// Does NOT support mid-pattern wildcards (e.g., "/v1/*/models").
129pub fn prefix_or_exact_match(pattern: &str, value: &str) -> bool {
130    if pattern == "/*" || pattern == "*" {
131        return true;
132    }
133    if let Some(prefix) = pattern.strip_suffix('*') {
134        value.starts_with(prefix)
135    } else {
136        pattern == value
137    }
138}
139
140/// Evaluate HTTP ACL rules against a request.
141///
142/// - Block rules are checked first; if any match, return false.
143/// - Allow rules are checked next; if any match, return true.
144/// - If allow rules exist but none matched, return false (deny-by-default).
145/// - If no rules at all, return true (unrestricted).
146pub fn http_acl_check(
147    allow: &[HttpRule],
148    deny: &[HttpRule],
149    method: &str,
150    host: &str,
151    path: &str,
152) -> bool {
153    // Block rules checked first
154    for rule in deny {
155        if rule.matches(method, host, path) {
156            return false;
157        }
158    }
159    // Allow rules checked next
160    if allow.is_empty() && deny.is_empty() {
161        return true; // unrestricted
162    }
163    if allow.is_empty() {
164        // Only block rules exist; anything not denied is allowed
165        return true;
166    }
167    for rule in allow {
168        if rule.matches(method, host, path) {
169            return true;
170        }
171    }
172    false // allow rules exist but none matched
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // --- HttpRule::parse tests ---
180
181    #[test]
182    fn parse_basic_get() {
183        let rule = HttpRule::parse("GET api.example.com/v1/*").unwrap();
184        assert_eq!(rule.method, "GET");
185        assert_eq!(rule.host, "api.example.com");
186        assert_eq!(rule.path, "/v1/*");
187    }
188
189    #[test]
190    fn parse_wildcard_method_and_host() {
191        let rule = HttpRule::parse("* */admin/*").unwrap();
192        assert_eq!(rule.method, "*");
193        assert_eq!(rule.host, "*");
194        assert_eq!(rule.path, "/admin/*");
195    }
196
197    #[test]
198    fn parse_post_with_exact_path() {
199        let rule = HttpRule::parse("POST example.com/upload").unwrap();
200        assert_eq!(rule.method, "POST");
201        assert_eq!(rule.host, "example.com");
202        assert_eq!(rule.path, "/upload");
203    }
204
205    #[test]
206    fn parse_no_path_defaults_to_wildcard() {
207        let rule = HttpRule::parse("GET example.com").unwrap();
208        assert_eq!(rule.method, "GET");
209        assert_eq!(rule.host, "example.com");
210        assert_eq!(rule.path, "/*");
211    }
212
213    #[test]
214    fn parse_method_uppercased() {
215        let rule = HttpRule::parse("get example.com/foo").unwrap();
216        assert_eq!(rule.method, "GET");
217    }
218
219    #[test]
220    fn parse_error_no_space() {
221        assert!(HttpRule::parse("GETexample.com").is_err());
222    }
223
224    #[test]
225    fn parse_error_empty_host() {
226        assert!(HttpRule::parse("GET  ").is_err());
227    }
228
229    // --- prefix_or_exact_match tests ---
230
231    #[test]
232    fn prefix_or_exact_match_wildcard_all() {
233        assert!(prefix_or_exact_match("/*", "/anything"));
234        assert!(prefix_or_exact_match("*", "/anything"));
235        assert!(prefix_or_exact_match("/*", "/"));
236    }
237
238    #[test]
239    fn prefix_or_exact_match_prefix() {
240        assert!(prefix_or_exact_match("/v1/*", "/v1/foo"));
241        assert!(prefix_or_exact_match("/v1/*", "/v1/foo/bar"));
242        assert!(prefix_or_exact_match("/v1/*", "/v1/"));
243        assert!(!prefix_or_exact_match("/v1/*", "/v2/foo"));
244    }
245
246    #[test]
247    fn prefix_or_exact_match_exact() {
248        assert!(prefix_or_exact_match("/v1/models", "/v1/models"));
249        assert!(!prefix_or_exact_match("/v1/models", "/v1/models/extra"));
250        assert!(!prefix_or_exact_match("/v1/models", "/v1/model"));
251    }
252
253    // --- HttpRule::matches tests ---
254
255    #[test]
256    fn matches_exact() {
257        let rule = HttpRule::parse("GET api.example.com/v1/models").unwrap();
258        assert!(rule.matches("GET", "api.example.com", "/v1/models"));
259        assert!(!rule.matches("POST", "api.example.com", "/v1/models"));
260        assert!(!rule.matches("GET", "other.com", "/v1/models"));
261        assert!(!rule.matches("GET", "api.example.com", "/v1/other"));
262    }
263
264    #[test]
265    fn matches_wildcard_method() {
266        let rule = HttpRule::parse("* api.example.com/v1/*").unwrap();
267        assert!(rule.matches("GET", "api.example.com", "/v1/foo"));
268        assert!(rule.matches("POST", "api.example.com", "/v1/bar"));
269    }
270
271    #[test]
272    fn matches_wildcard_host() {
273        let rule = HttpRule::parse("GET */v1/*").unwrap();
274        assert!(rule.matches("GET", "any.host.com", "/v1/foo"));
275    }
276
277    #[test]
278    fn matches_case_insensitive_method() {
279        let rule = HttpRule::parse("GET example.com/foo").unwrap();
280        assert!(rule.matches("get", "example.com", "/foo"));
281        assert!(rule.matches("Get", "example.com", "/foo"));
282    }
283
284    #[test]
285    fn matches_case_insensitive_host() {
286        let rule = HttpRule::parse("GET Example.COM/foo").unwrap();
287        assert!(rule.matches("GET", "example.com", "/foo"));
288    }
289
290    // --- http_acl_check tests ---
291
292    #[test]
293    fn acl_no_rules_allows_all() {
294        assert!(http_acl_check(&[], &[], "GET", "example.com", "/foo"));
295    }
296
297    #[test]
298    fn acl_allow_only_permits_matching() {
299        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
300        assert!(http_acl_check(&allow, &[], "GET", "api.example.com", "/v1/foo"));
301        assert!(!http_acl_check(&allow, &[], "POST", "api.example.com", "/v1/foo"));
302        assert!(!http_acl_check(&allow, &[], "GET", "other.com", "/v1/foo"));
303    }
304
305    #[test]
306    fn acl_deny_only_blocks_matching() {
307        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
308        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
309        assert!(http_acl_check(&[], &deny, "GET", "example.com", "/public/page"));
310    }
311
312    #[test]
313    fn acl_deny_takes_precedence_over_allow() {
314        let allow = vec![HttpRule::parse("* example.com/*").unwrap()];
315        let deny = vec![HttpRule::parse("* example.com/admin/*").unwrap()];
316        assert!(http_acl_check(&allow, &deny, "GET", "example.com", "/public"));
317        assert!(!http_acl_check(&allow, &deny, "GET", "example.com", "/admin/settings"));
318    }
319
320    #[test]
321    fn acl_allow_deny_by_default_when_no_match() {
322        let allow = vec![HttpRule::parse("GET api.example.com/v1/*").unwrap()];
323        // Different host, not matched by allow -> denied
324        assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
325    }
326
327    // --- normalize_path tests ---
328
329    #[test]
330    fn normalize_path_basic() {
331        assert_eq!(normalize_path("/foo/bar"), "/foo/bar");
332        assert_eq!(normalize_path("/"), "/");
333    }
334
335    #[test]
336    fn normalize_path_double_slashes() {
337        assert_eq!(normalize_path("/foo//bar"), "/foo/bar");
338        assert_eq!(normalize_path("//foo///bar//"), "/foo/bar");
339    }
340
341    #[test]
342    fn normalize_path_dot_segments() {
343        assert_eq!(normalize_path("/foo/./bar"), "/foo/bar");
344        assert_eq!(normalize_path("/foo/../bar"), "/bar");
345        assert_eq!(normalize_path("/foo/bar/../../baz"), "/baz");
346    }
347
348    #[test]
349    fn normalize_path_dotdot_at_root() {
350        assert_eq!(normalize_path("/../foo"), "/foo");
351        assert_eq!(normalize_path("/../../foo"), "/foo");
352    }
353
354    #[test]
355    fn normalize_path_percent_encoding() {
356        // %2F = /, %61 = a
357        assert_eq!(normalize_path("/foo%2Fbar"), "/foo/bar");
358        assert_eq!(normalize_path("/%61dmin/settings"), "/admin/settings");
359    }
360
361    #[test]
362    fn normalize_path_mixed_bypass_attempts() {
363        // Double-encoded traversal
364        assert_eq!(normalize_path("/v1/./admin/settings"), "/v1/admin/settings");
365        assert_eq!(normalize_path("/v1/../admin/settings"), "/admin/settings");
366        assert_eq!(normalize_path("/v1//admin/settings"), "/v1/admin/settings");
367        assert_eq!(normalize_path("/v1/%2e%2e/admin"), "/admin");
368    }
369
370    // --- ACL bypass prevention tests ---
371
372    #[test]
373    fn acl_deny_prevents_double_slash_bypass() {
374        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
375        // These should all be caught by the deny rule
376        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin/settings"));
377        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "//admin/settings"));
378        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/admin//settings"));
379    }
380
381    #[test]
382    fn acl_deny_prevents_dot_segment_bypass() {
383        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
384        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/./admin/settings"));
385        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/public/../admin/settings"));
386    }
387
388    #[test]
389    fn acl_deny_prevents_percent_encoding_bypass() {
390        let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
391        // %61dmin = admin
392        assert!(!http_acl_check(&[], &deny, "GET", "example.com", "/%61dmin/settings"));
393    }
394
395    #[test]
396    fn acl_allow_normalized_path_still_works() {
397        let allow = vec![HttpRule::parse("GET example.com/v1/models").unwrap()];
398        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/models"));
399        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1/./models"));
400        assert!(http_acl_check(&allow, &[], "GET", "example.com", "/v1//models"));
401        // These resolve to different paths and should be denied
402        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v1/models/extra"));
403        assert!(!http_acl_check(&allow, &[], "GET", "example.com", "/v2/models"));
404    }
405
406    #[test]
407    fn parse_normalizes_rule_path() {
408        let rule = HttpRule::parse("GET example.com/v1/./models/*").unwrap();
409        assert_eq!(rule.path, "/v1/models/*");
410
411        let rule = HttpRule::parse("GET example.com/v1//models").unwrap();
412        assert_eq!(rule.path, "/v1/models");
413    }
414}