Skip to main content

sandlock_core/
http.rs

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