1use serde::{Deserialize, Serialize};
2
3use crate::error::SandboxError;
4
5#[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 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 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 pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
54 if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
56 return false;
57 }
58 if self.host != "*" && !self.host.eq_ignore_ascii_case(host) {
60 return false;
61 }
62 let normalized = normalize_path(path);
64 prefix_or_exact_match(&self.path, &normalized)
65 }
66}
67
68pub fn normalize_path(path: &str) -> String {
75 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 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 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 let mut result = String::with_capacity(decoded.len());
116 result.push('/');
117 result.push_str(&segments.join("/"));
118 result
119}
120
121pub 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
140pub fn http_acl_check(
147 allow: &[HttpRule],
148 deny: &[HttpRule],
149 method: &str,
150 host: &str,
151 path: &str,
152) -> bool {
153 for rule in deny {
155 if rule.matches(method, host, path) {
156 return false;
157 }
158 }
159 if allow.is_empty() && deny.is_empty() {
161 return true; }
163 if allow.is_empty() {
164 return true;
166 }
167 for rule in allow {
168 if rule.matches(method, host, path) {
169 return true;
170 }
171 }
172 false }
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[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 #[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 #[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 #[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 assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
325 }
326
327 #[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 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 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 #[test]
373 fn acl_deny_prevents_double_slash_bypass() {
374 let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
375 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 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 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}