Skip to main content

http_acl/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4pub use ipnet::IpNet;
5
6pub mod acl;
7pub mod error;
8pub mod mutation;
9pub mod utils;
10
11pub use acl::{
12    AclClassification, HttpAcl, HttpAclBuilder, HttpAclHooks, HttpRequestMethod, ValidateFn,
13};
14pub use mutation::{ModifyRequestFn, ModifyResponseFn, RequestMutation, ResponseMutation};
15pub use utils::IntoIpRange;
16
17#[cfg(test)]
18mod tests {
19    use std::net::IpAddr;
20    use std::sync::Arc;
21
22    use ipnet::IpNet;
23
24    use super::{AclClassification, HttpAclBuilder, HttpAclHooks};
25
26    #[test]
27    fn acl() {
28        let acl = HttpAclBuilder::new()
29            .add_allowed_host("example.com".to_string())
30            .unwrap()
31            .add_allowed_host("example.org".to_string())
32            .unwrap()
33            .add_denied_host("example.net".to_string())
34            .unwrap()
35            .add_allowed_port_range(8080..=8080)
36            .unwrap()
37            .add_denied_port_range(8443..=8443)
38            .unwrap()
39            .add_allowed_ip_range("1.0.0.0/8".parse::<IpNet>().unwrap())
40            .unwrap()
41            .add_denied_ip_range("9.0.0.0/8".parse::<IpNet>().unwrap())
42            .unwrap()
43            .try_build()
44            .unwrap();
45
46        assert!(acl.is_host_allowed("example.com").is_allowed());
47        assert!(acl.is_host_allowed("example.org").is_allowed());
48        assert!(!acl.is_host_allowed("example.net").is_allowed());
49        assert!(acl.is_port_allowed(8080).is_allowed());
50        assert!(!acl.is_port_allowed(8443).is_allowed());
51        assert!(acl.is_ip_allowed(&"1.1.1.1".parse().unwrap()).is_allowed());
52        assert!(acl.is_ip_allowed(&"9.9.9.9".parse().unwrap()).is_denied());
53        assert!(
54            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
55                .is_denied()
56        );
57    }
58
59    #[test]
60    fn host_acl() {
61        let acl = HttpAclBuilder::new()
62            .add_allowed_host("example.com".to_string())
63            .unwrap()
64            .add_allowed_host("example.org".to_string())
65            .unwrap()
66            .add_denied_host("example.net".to_string())
67            .unwrap()
68            .try_build()
69            .unwrap();
70
71        assert!(acl.is_host_allowed("example.com").is_allowed());
72        assert!(acl.is_host_allowed("example.org").is_allowed());
73        assert!(!acl.is_host_allowed("example.net").is_allowed());
74    }
75
76    #[test]
77    fn wildcard_host_acl() {
78        let acl = HttpAclBuilder::new()
79            .add_allowed_host("*.example.com".to_string())
80            .unwrap()
81            .add_allowed_host("?.example.org".to_string())
82            .unwrap()
83            .add_denied_host("secret.example.com".to_string())
84            .unwrap()
85            .try_build()
86            .unwrap();
87
88        // `*` matches one or more labels.
89        assert!(acl.is_host_allowed("foo.example.com").is_allowed());
90        assert!(acl.is_host_allowed("foo.bar.example.com").is_allowed());
91        assert!(!acl.is_host_allowed("example.com").is_allowed());
92
93        // `?` matches exactly one label.
94        assert!(acl.is_host_allowed("foo.example.org").is_allowed());
95        assert!(!acl.is_host_allowed("foo.bar.example.org").is_allowed());
96        assert!(!acl.is_host_allowed("example.org").is_allowed());
97
98        // Allow-list entries (including wildcard ones) are checked before deny-list
99        // entries, same as for exact hosts - a broad wildcard allow can shadow a more
100        // specific deny, so `secret.example.com` here is allowed, not denied.
101        assert!(acl.is_host_allowed("secret.example.com").is_allowed());
102
103        // Unrelated hosts fall through to the default (deny).
104        assert!(acl.is_host_allowed("example.net").is_denied());
105    }
106
107    #[test]
108    fn invalid_wildcard_host_pattern_rejected() {
109        assert!(
110            HttpAclBuilder::new()
111                .add_allowed_host("foo*.example.com".to_string())
112                .is_err()
113        );
114    }
115
116    #[test]
117    fn port_acl() {
118        let acl = HttpAclBuilder::new()
119            .clear_allowed_port_ranges()
120            .add_allowed_port_range(8080..=8080)
121            .unwrap()
122            .add_denied_port_range(8441..=8443)
123            .unwrap()
124            .try_build()
125            .unwrap();
126
127        assert!(acl.is_port_allowed(80).is_denied());
128        assert!(acl.is_port_allowed(8080).is_allowed());
129        assert!(acl.is_port_allowed(8440).is_denied());
130        assert!(!acl.is_port_allowed(8441).is_allowed());
131        assert!(!acl.is_port_allowed(8442).is_allowed());
132        assert!(!acl.is_port_allowed(8443).is_allowed());
133        assert!(acl.is_port_allowed(8444).is_denied());
134    }
135
136    #[test]
137    fn denied_port_ranges_setter_rejects_overlaps() {
138        // Overlaps (but doesn't exactly equal) the default allowed range `80..=80`.
139        assert!(
140            HttpAclBuilder::new()
141                .denied_port_ranges(vec![70..=85])
142                .is_err()
143        );
144
145        // Overlaps within the new list itself.
146        assert!(
147            HttpAclBuilder::new()
148                .denied_port_ranges(vec![1000..=2000, 1500..=2500])
149                .is_err()
150        );
151    }
152
153    #[test]
154    fn mixed_family_ip_range_rejected() {
155        let start: IpAddr = "1.0.0.0".parse().unwrap();
156        let end: IpAddr = "::1".parse().unwrap();
157        assert!(
158            HttpAclBuilder::new()
159                .add_allowed_ip_range((start, end))
160                .is_err()
161        );
162    }
163
164    #[test]
165    fn ip_acl() {
166        let acl = HttpAclBuilder::new()
167            .clear_allowed_ip_ranges()
168            .add_allowed_ip_range("1.0.0.0/8".parse::<IpNet>().unwrap())
169            .unwrap()
170            .add_denied_ip_range("9.0.0.0/8".parse::<IpNet>().unwrap())
171            .unwrap()
172            .try_build()
173            .unwrap();
174
175        assert!(acl.is_ip_allowed(&"1.1.1.1".parse().unwrap()).is_allowed());
176        assert!(acl.is_ip_allowed(&"9.9.9.9".parse().unwrap()).is_denied());
177        assert!(
178            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
179                .is_denied()
180        );
181    }
182
183    #[test]
184    fn non_global_ip_acl() {
185        let acl = HttpAclBuilder::new()
186            .non_global_ip_ranges(true)
187            .ip_acl_default(true)
188            .try_build()
189            .unwrap();
190
191        assert!(
192            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
193                .is_allowed()
194        );
195        assert!(
196            acl.is_ip_allowed(&"203.0.113.12".parse().unwrap())
197                .is_allowed()
198        );
199
200        let acl = HttpAclBuilder::new()
201            .ip_acl_default(true)
202            .try_build()
203            .unwrap();
204
205        assert!(
206            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
207                .is_denied()
208        );
209        assert!(
210            acl.is_ip_allowed(&"203.0.113.12".parse().unwrap())
211                .is_denied()
212        );
213    }
214
215    #[test]
216    fn default_ip_acl() {
217        let acl = HttpAclBuilder::new().try_build().unwrap();
218
219        assert!(
220            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
221                .is_denied()
222        );
223        assert!(acl.is_ip_allowed(&"1.1.1.1".parse().unwrap()).is_denied());
224        assert!(!acl.is_port_allowed(8080).is_allowed());
225    }
226
227    #[test]
228    fn url_path_acl() {
229        let acl = HttpAclBuilder::new()
230            .add_allowed_url_path("/allowed".to_string())
231            .unwrap()
232            .add_allowed_url_path("/allowed/:id".to_string())
233            .unwrap()
234            .add_denied_url_path("/denied".to_string())
235            .unwrap()
236            .add_denied_url_path("/denied/{*path}".to_string())
237            .unwrap()
238            .try_build()
239            .unwrap();
240
241        assert!(acl.is_url_path_allowed("/allowed").is_allowed());
242        assert!(acl.is_url_path_allowed("/allowed/allowed").is_allowed());
243        assert!(acl.is_url_path_allowed("/denied").is_denied());
244        assert!(acl.is_url_path_allowed("/denied/denied").is_denied());
245        assert!(acl.is_url_path_allowed("/denied/denied/denied").is_denied());
246    }
247
248    #[test]
249    fn header_acl() {
250        let acl = HttpAclBuilder::new()
251            .add_allowed_header("X-Allowed".to_string(), Some("true".to_string()))
252            .unwrap()
253            .add_allowed_header("X-Allowed2".to_string(), None)
254            .unwrap()
255            .add_denied_header("X-Denied".to_string(), Some("true".to_string()))
256            .unwrap()
257            .add_denied_header("X-Denied2".to_string(), None)
258            .unwrap()
259            .try_build()
260            .unwrap();
261
262        assert!(acl.is_header_allowed("X-Allowed", "true").is_allowed());
263        assert!(acl.is_header_allowed("X-Allowed2", "false").is_allowed());
264        assert!(acl.is_header_allowed("X-Denied", "true").is_denied());
265        assert!(acl.is_header_allowed("X-Denied2", "false").is_denied());
266    }
267
268    #[test]
269    fn header_acl_bytes_compares_raw_bytes() {
270        // U+FFFD is what a lossy UTF-8 decode turns a stray 0xE9 into, so an
271        // allow-listed value containing it is the case a lossy comparison would let
272        // a different raw byte sequence through.
273        let acl = HttpAclBuilder::new()
274            .add_allowed_header("x-allowed".to_string(), Some("caf\u{FFFD}".to_string()))
275            .unwrap()
276            .add_allowed_header("x-any".to_string(), None)
277            .unwrap()
278            .add_denied_header("x-denied".to_string(), None)
279            .unwrap()
280            .add_denied_header("x-denied-value".to_string(), Some("caf\u{e9}".to_string()))
281            .unwrap()
282            .header_acl_default(false)
283            .try_build()
284            .unwrap();
285
286        assert!(
287            acl.is_header_allowed_bytes("x-allowed", "caf\u{FFFD}".as_bytes())
288                .is_allowed()
289        );
290        assert!(
291            acl.is_header_allowed_bytes("x-allowed", b"caf\xE9")
292                .is_denied()
293        );
294        assert!(
295            acl.is_header_allowed_bytes("x-any", b"caf\xE9")
296                .is_allowed()
297        );
298        assert!(
299            acl.is_header_allowed_bytes("x-denied", b"caf\xE9")
300                .is_denied()
301        );
302        assert!(
303            acl.is_header_allowed_bytes("x-denied-value", "caf\u{e9}".as_bytes())
304                .is_denied()
305        );
306        assert!(
307            acl.is_header_allowed_bytes("x-denied-value", b"caf\xE9")
308                .is_allowed()
309        );
310        assert!(
311            acl.is_header_allowed_bytes("x-other", b"caf\xE9")
312                .is_denied()
313        );
314    }
315
316    #[test]
317    fn static_dns_mapping() {
318        let regular_addr = "10.0.0.1:80".parse().unwrap();
319        let trusted_addr = "10.0.0.2:80".parse().unwrap();
320
321        let acl = HttpAclBuilder::new()
322            .add_static_dns_mapping("regular.example.com".to_string(), regular_addr)
323            .unwrap()
324            .add_trusted_static_dns_mapping("trusted.example.com".to_string(), trusted_addr)
325            .unwrap()
326            .try_build()
327            .unwrap();
328
329        assert_eq!(
330            acl.resolve_static_dns_mapping("regular.example.com"),
331            Some(regular_addr)
332        );
333        assert_eq!(
334            acl.resolve_trusted_static_dns_mapping("trusted.example.com"),
335            Some(trusted_addr)
336        );
337        // The two maps are looked up independently - a host in one isn't found in the other.
338        assert_eq!(
339            acl.resolve_trusted_static_dns_mapping("regular.example.com"),
340            None
341        );
342        assert_eq!(acl.resolve_static_dns_mapping("trusted.example.com"), None);
343
344        // A regular mapping's resolved address is still subject to the IP/port ACL
345        // (denied here because no IP ranges are allowed by default).
346        assert!(acl.is_ip_allowed(&regular_addr.ip()).is_denied());
347
348        // A host can't be present in both maps.
349        assert!(
350            HttpAclBuilder::new()
351                .add_static_dns_mapping("both.example.com".to_string(), regular_addr)
352                .unwrap()
353                .add_trusted_static_dns_mapping("both.example.com".to_string(), trusted_addr)
354                .is_err()
355        );
356        assert!(
357            HttpAclBuilder::new()
358                .add_trusted_static_dns_mapping("both.example.com".to_string(), trusted_addr)
359                .unwrap()
360                .add_static_dns_mapping("both.example.com".to_string(), regular_addr)
361                .is_err()
362        );
363    }
364
365    #[test]
366    fn valid_acl() {
367        let acl = HttpAclBuilder::new()
368            .try_build_full(HttpAclHooks {
369                validate_fn: Some(Arc::new(|scheme, authority, headers, body| {
370                    if scheme == "http" {
371                        return AclClassification::DeniedUserAcl;
372                    }
373
374                    if authority.host.is_ip() {
375                        return AclClassification::DeniedUserAcl;
376                    }
377
378                    for (header_name, header_value) in headers {
379                        if header_name == "<dangerous-header>"
380                            && header_value == "<dangerous-value>"
381                        {
382                            return AclClassification::DeniedUserAcl;
383                        }
384                    }
385
386                    if let Some(body) = body
387                        && body == b"<dangerous-body>"
388                    {
389                        return AclClassification::DeniedUserAcl;
390                    }
391
392                    AclClassification::AllowedDefault
393                })),
394                ..Default::default()
395            })
396            .unwrap();
397
398        assert!(
399            acl.is_valid(
400                "https",
401                &"example.com".into(),
402                [("<header>", "<value>")].into_iter(),
403                Some(b"body"),
404            )
405            .is_allowed()
406        );
407        assert!(
408            acl.is_valid(
409                "http",
410                &"example.com".into(),
411                [("<header>", "<value>")].into_iter(),
412                Some(b"body"),
413            )
414            .is_denied()
415        );
416        assert!(
417            acl.is_valid(
418                "https",
419                &"1.1.1.1".parse::<IpAddr>().unwrap().into(),
420                [("<header>", "<value>")].into_iter(),
421                Some(b"body"),
422            )
423            .is_denied()
424        );
425        assert!(
426            acl.is_valid(
427                "https",
428                &"example.com".into(),
429                [("<dangerous-header>", "<dangerous-value>")].into_iter(),
430                Some(b"body"),
431            )
432            .is_denied()
433        );
434        assert!(
435            acl.is_valid(
436                "https",
437                &"example.com".into(),
438                [("<header>", "<value>")].into_iter(),
439                Some(b"<dangerous-body>"),
440            )
441            .is_denied()
442        );
443    }
444
445    #[test]
446    fn modify_hooks_unset_by_default() {
447        let acl = HttpAclBuilder::new().build();
448
449        assert!(!acl.has_modify_request());
450        assert!(!acl.has_modify_response());
451
452        let mut request_mutation = super::RequestMutation::default();
453        acl.modify_request("https", &"example.com".into(), &mut request_mutation);
454        assert_eq!(request_mutation, super::RequestMutation::default());
455
456        let mut response_mutation = super::ResponseMutation {
457            status: 200,
458            headers: vec![("content-type".to_string(), "text/plain".to_string())],
459            body: bytes::Bytes::from_static(b"body"),
460        };
461        let unchanged = response_mutation.clone();
462        acl.modify_response("https", &"example.com".into(), &mut response_mutation);
463        assert_eq!(response_mutation, unchanged);
464    }
465
466    #[test]
467    fn modify_hooks_invoked_with_expected_context() {
468        let acl = HttpAclBuilder::new().build_full(HttpAclHooks {
469            modify_request_fn: Some(std::sync::Arc::new(|scheme, authority, mutation| {
470                mutation
471                    .headers
472                    .push(("x-injected".to_string(), format!("{scheme}://{authority}")));
473                mutation.body = Some(bytes::Bytes::from_static(b"replaced"));
474            })),
475            modify_response_fn: Some(std::sync::Arc::new(|scheme, authority, mutation| {
476                mutation.status = 201;
477                mutation.headers.retain(|(k, _)| k != "x-remove-me");
478                mutation.body = bytes::Bytes::from(format!("{scheme}://{authority}"));
479            })),
480            ..Default::default()
481        });
482
483        assert!(acl.has_modify_request());
484        assert!(acl.has_modify_response());
485
486        let mut request_mutation = super::RequestMutation::default();
487        acl.modify_request("https", &"example.com".into(), &mut request_mutation);
488        assert_eq!(
489            request_mutation.headers,
490            vec![("x-injected".to_string(), "https://example.com".to_string())]
491        );
492        assert_eq!(
493            request_mutation.body,
494            Some(bytes::Bytes::from_static(b"replaced"))
495        );
496
497        let mut response_mutation = super::ResponseMutation {
498            status: 200,
499            headers: vec![
500                ("x-remove-me".to_string(), "yes".to_string()),
501                ("x-keep-me".to_string(), "yes".to_string()),
502            ],
503            body: bytes::Bytes::new(),
504        };
505        acl.modify_response("https", &"example.com".into(), &mut response_mutation);
506        assert_eq!(response_mutation.status, 201);
507        assert_eq!(
508            response_mutation.headers,
509            vec![("x-keep-me".to_string(), "yes".to_string())]
510        );
511        assert_eq!(response_mutation.body, "https://example.com");
512    }
513}