1use serde::{Deserialize, Serialize};
2
3use crate::error::SandboxError;
4use crate::network::{NetAllow, NetTarget, Protocol};
5
6#[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 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 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 pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
55 if self.method != "*" && !self.method.eq_ignore_ascii_case(method) {
57 return false;
58 }
59 if self.host != "*" && !self.host.eq_ignore_ascii_case(host) {
61 return false;
62 }
63 let normalized = normalize_path(path);
65 prefix_or_exact_match(&self.path, &normalized)
66 }
67}
68
69pub fn normalize_path(path: &str) -> String {
76 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 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 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 let mut result = String::with_capacity(decoded.len());
117 result.push('/');
118 result.push_str(&segments.join("/"));
119 result
120}
121
122pub 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
141pub fn http_acl_check(
148 allow: &[HttpRule],
149 deny: &[HttpRule],
150 method: &str,
151 host: &str,
152 path: &str,
153) -> bool {
154 for rule in deny {
156 if rule.matches(method, host, path) {
157 return false;
158 }
159 }
160 if allow.is_empty() && deny.is_empty() {
162 return true; }
164 if allow.is_empty() {
165 return true;
167 }
168 for rule in allow {
169 if rule.matches(method, host, path) {
170 return true;
171 }
172 }
173 false }
175
176pub(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 #[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 #[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 #[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 #[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 assert!(!http_acl_check(&allow, &[], "GET", "evil.com", "/v1/foo"));
374 }
375
376 #[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 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 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 #[test]
422 fn acl_deny_prevents_double_slash_bypass() {
423 let deny = vec![HttpRule::parse("* */admin/*").unwrap()];
424 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 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 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}