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 ipv4_embedding_ipv6_denied_by_default() {
185        // hisn-io/hisn#2821: each of these IPv6 forms carries an IPv4 address, so it can
186        // name loopback or a private network and must be as closed as that range.
187        let probes = [
188            "::7f00:1",           // IPv4-compatible ::/96, 127.0.0.1
189            "::a00:1",            // IPv4-compatible ::/96, 10.0.0.1
190            "::808:808",          // IPv4-compatible ::/96, 8.8.8.8
191            "64:ff9b::7f00:1",    // NAT64 64:ff9b::/96, 127.0.0.1
192            "64:ff9b::a9fe:a9fe", // NAT64 64:ff9b::/96, 169.254.169.254
193            "2002:7f00:1::",      // 6to4 2002::/16, 127.0.0.1
194            "2002:c0a8:101::1",   // 6to4 2002::/16, 192.168.1.1
195            "::ffff:0:7f00:1",    // IPv4-translated ::ffff:0:0:0/96, 127.0.0.1
196            "::ffff:7f00:1",      // IPv4-mapped ::ffff:0:0/96, already denied
197            "64:ff9b:1::7f00:1",  // local-use translation 64:ff9b:1::/48, already denied
198        ];
199
200        let acl = HttpAclBuilder::new()
201            .ip_acl_default(true)
202            .try_build()
203            .unwrap();
204        for probe in probes {
205            assert!(
206                acl.is_ip_allowed(&probe.parse().unwrap()).is_denied(),
207                "{probe} should be denied"
208            );
209        }
210        assert!(
211            acl.is_ip_allowed(&"2606:4700:4700::1111".parse().unwrap())
212                .is_allowed()
213        );
214
215        let acl = HttpAclBuilder::new()
216            .non_global_ip_ranges(true)
217            .ip_acl_default(true)
218            .try_build()
219            .unwrap();
220        for probe in probes {
221            assert!(
222                acl.is_ip_allowed(&probe.parse().unwrap()).is_allowed(),
223                "{probe} should be allowed with non-global ranges on"
224            );
225        }
226
227        for range in [
228            "::7f00:0/104",
229            "64:ff9b::/96",
230            "2002::/16",
231            "::ffff:0:0:0/96",
232        ] {
233            assert!(
234                HttpAclBuilder::new()
235                    .add_allowed_ip_range(range.parse::<IpNet>().unwrap())
236                    .unwrap()
237                    .try_build()
238                    .is_err(),
239                "{range} should be refused as an allowed range"
240            );
241        }
242    }
243
244    #[test]
245    fn non_global_ip_acl() {
246        let acl = HttpAclBuilder::new()
247            .non_global_ip_ranges(true)
248            .ip_acl_default(true)
249            .try_build()
250            .unwrap();
251
252        assert!(
253            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
254                .is_allowed()
255        );
256        assert!(
257            acl.is_ip_allowed(&"203.0.113.12".parse().unwrap())
258                .is_allowed()
259        );
260
261        let acl = HttpAclBuilder::new()
262            .ip_acl_default(true)
263            .try_build()
264            .unwrap();
265
266        assert!(
267            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
268                .is_denied()
269        );
270        assert!(
271            acl.is_ip_allowed(&"203.0.113.12".parse().unwrap())
272                .is_denied()
273        );
274    }
275
276    #[test]
277    fn default_ip_acl() {
278        let acl = HttpAclBuilder::new().try_build().unwrap();
279
280        assert!(
281            acl.is_ip_allowed(&"192.168.1.1".parse().unwrap())
282                .is_denied()
283        );
284        assert!(acl.is_ip_allowed(&"1.1.1.1".parse().unwrap()).is_denied());
285        assert!(!acl.is_port_allowed(8080).is_allowed());
286    }
287
288    #[test]
289    fn url_path_acl() {
290        let acl = HttpAclBuilder::new()
291            .add_allowed_url_path("/allowed".to_string())
292            .unwrap()
293            .add_allowed_url_path("/allowed/:id".to_string())
294            .unwrap()
295            .add_denied_url_path("/denied".to_string())
296            .unwrap()
297            .add_denied_url_path("/denied/{*path}".to_string())
298            .unwrap()
299            .try_build()
300            .unwrap();
301
302        assert!(acl.is_url_path_allowed("/allowed").is_allowed());
303        assert!(acl.is_url_path_allowed("/allowed/allowed").is_allowed());
304        assert!(acl.is_url_path_allowed("/denied").is_denied());
305        assert!(acl.is_url_path_allowed("/denied/denied").is_denied());
306        assert!(acl.is_url_path_allowed("/denied/denied/denied").is_denied());
307    }
308
309    #[test]
310    fn header_acl() {
311        let acl = HttpAclBuilder::new()
312            .add_allowed_header("X-Allowed".to_string(), Some("true".to_string()))
313            .unwrap()
314            .add_allowed_header("X-Allowed2".to_string(), None)
315            .unwrap()
316            .add_denied_header("X-Denied".to_string(), Some("true".to_string()))
317            .unwrap()
318            .add_denied_header("X-Denied2".to_string(), None)
319            .unwrap()
320            .try_build()
321            .unwrap();
322
323        assert!(acl.is_header_allowed("X-Allowed", "true").is_allowed());
324        assert!(acl.is_header_allowed("X-Allowed2", "false").is_allowed());
325        assert!(acl.is_header_allowed("X-Denied", "true").is_denied());
326        assert!(acl.is_header_allowed("X-Denied2", "false").is_denied());
327    }
328
329    #[test]
330    fn header_acl_bytes_compares_raw_bytes() {
331        // U+FFFD is what a lossy UTF-8 decode turns a stray 0xE9 into, so an
332        // allow-listed value containing it is the case a lossy comparison would let
333        // a different raw byte sequence through.
334        let acl = HttpAclBuilder::new()
335            .add_allowed_header("x-allowed".to_string(), Some("caf\u{FFFD}".to_string()))
336            .unwrap()
337            .add_allowed_header("x-any".to_string(), None)
338            .unwrap()
339            .add_denied_header("x-denied".to_string(), None)
340            .unwrap()
341            .add_denied_header("x-denied-value".to_string(), Some("caf\u{e9}".to_string()))
342            .unwrap()
343            .header_acl_default(false)
344            .try_build()
345            .unwrap();
346
347        assert!(
348            acl.is_header_allowed_bytes("x-allowed", "caf\u{FFFD}".as_bytes())
349                .is_allowed()
350        );
351        assert!(
352            acl.is_header_allowed_bytes("x-allowed", b"caf\xE9")
353                .is_denied()
354        );
355        assert!(
356            acl.is_header_allowed_bytes("x-any", b"caf\xE9")
357                .is_allowed()
358        );
359        assert!(
360            acl.is_header_allowed_bytes("x-denied", b"caf\xE9")
361                .is_denied()
362        );
363        assert!(
364            acl.is_header_allowed_bytes("x-denied-value", "caf\u{e9}".as_bytes())
365                .is_denied()
366        );
367        assert!(
368            acl.is_header_allowed_bytes("x-denied-value", b"caf\xE9")
369                .is_allowed()
370        );
371        assert!(
372            acl.is_header_allowed_bytes("x-other", b"caf\xE9")
373                .is_denied()
374        );
375    }
376
377    #[test]
378    fn static_dns_mapping() {
379        let regular_addr = "10.0.0.1:80".parse().unwrap();
380        let trusted_addr = "10.0.0.2:80".parse().unwrap();
381
382        let acl = HttpAclBuilder::new()
383            .add_static_dns_mapping("regular.example.com".to_string(), regular_addr)
384            .unwrap()
385            .add_trusted_static_dns_mapping("trusted.example.com".to_string(), trusted_addr)
386            .unwrap()
387            .try_build()
388            .unwrap();
389
390        assert_eq!(
391            acl.resolve_static_dns_mapping("regular.example.com"),
392            Some(regular_addr)
393        );
394        assert_eq!(
395            acl.resolve_trusted_static_dns_mapping("trusted.example.com"),
396            Some(trusted_addr)
397        );
398        // The two maps are looked up independently - a host in one isn't found in the other.
399        assert_eq!(
400            acl.resolve_trusted_static_dns_mapping("regular.example.com"),
401            None
402        );
403        assert_eq!(acl.resolve_static_dns_mapping("trusted.example.com"), None);
404
405        // A regular mapping's resolved address is still subject to the IP/port ACL
406        // (denied here because no IP ranges are allowed by default).
407        assert!(acl.is_ip_allowed(&regular_addr.ip()).is_denied());
408
409        // A host can't be present in both maps.
410        assert!(
411            HttpAclBuilder::new()
412                .add_static_dns_mapping("both.example.com".to_string(), regular_addr)
413                .unwrap()
414                .add_trusted_static_dns_mapping("both.example.com".to_string(), trusted_addr)
415                .is_err()
416        );
417        assert!(
418            HttpAclBuilder::new()
419                .add_trusted_static_dns_mapping("both.example.com".to_string(), trusted_addr)
420                .unwrap()
421                .add_static_dns_mapping("both.example.com".to_string(), regular_addr)
422                .is_err()
423        );
424    }
425
426    #[test]
427    fn valid_acl() {
428        let acl = HttpAclBuilder::new()
429            .try_build_full(HttpAclHooks {
430                validate_fn: Some(Arc::new(|scheme, authority, headers, body| {
431                    if scheme == "http" {
432                        return AclClassification::DeniedUserAcl;
433                    }
434
435                    if authority.host.is_ip() {
436                        return AclClassification::DeniedUserAcl;
437                    }
438
439                    for (header_name, header_value) in headers {
440                        if header_name == "<dangerous-header>"
441                            && header_value == "<dangerous-value>"
442                        {
443                            return AclClassification::DeniedUserAcl;
444                        }
445                    }
446
447                    if let Some(body) = body
448                        && body == b"<dangerous-body>"
449                    {
450                        return AclClassification::DeniedUserAcl;
451                    }
452
453                    AclClassification::AllowedDefault
454                })),
455                ..Default::default()
456            })
457            .unwrap();
458
459        assert!(
460            acl.is_valid(
461                "https",
462                &"example.com".into(),
463                [("<header>", "<value>")].into_iter(),
464                Some(b"body"),
465            )
466            .is_allowed()
467        );
468        assert!(
469            acl.is_valid(
470                "http",
471                &"example.com".into(),
472                [("<header>", "<value>")].into_iter(),
473                Some(b"body"),
474            )
475            .is_denied()
476        );
477        assert!(
478            acl.is_valid(
479                "https",
480                &"1.1.1.1".parse::<IpAddr>().unwrap().into(),
481                [("<header>", "<value>")].into_iter(),
482                Some(b"body"),
483            )
484            .is_denied()
485        );
486        assert!(
487            acl.is_valid(
488                "https",
489                &"example.com".into(),
490                [("<dangerous-header>", "<dangerous-value>")].into_iter(),
491                Some(b"body"),
492            )
493            .is_denied()
494        );
495        assert!(
496            acl.is_valid(
497                "https",
498                &"example.com".into(),
499                [("<header>", "<value>")].into_iter(),
500                Some(b"<dangerous-body>"),
501            )
502            .is_denied()
503        );
504    }
505
506    #[test]
507    fn modify_hooks_unset_by_default() {
508        let acl = HttpAclBuilder::new().build();
509
510        assert!(!acl.has_modify_request());
511        assert!(!acl.has_modify_response());
512
513        let mut request_mutation = super::RequestMutation::default();
514        acl.modify_request("https", &"example.com".into(), &mut request_mutation);
515        assert_eq!(request_mutation, super::RequestMutation::default());
516
517        let mut response_mutation = super::ResponseMutation {
518            status: 200,
519            headers: vec![("content-type".to_string(), "text/plain".to_string())],
520            body: bytes::Bytes::from_static(b"body"),
521        };
522        let unchanged = response_mutation.clone();
523        acl.modify_response("https", &"example.com".into(), &mut response_mutation);
524        assert_eq!(response_mutation, unchanged);
525    }
526
527    #[test]
528    fn modify_hooks_invoked_with_expected_context() {
529        let acl = HttpAclBuilder::new().build_full(HttpAclHooks {
530            modify_request_fn: Some(std::sync::Arc::new(|scheme, authority, mutation| {
531                mutation
532                    .headers
533                    .push(("x-injected".to_string(), format!("{scheme}://{authority}")));
534                mutation.body = Some(bytes::Bytes::from_static(b"replaced"));
535            })),
536            modify_response_fn: Some(std::sync::Arc::new(|scheme, authority, mutation| {
537                mutation.status = 201;
538                mutation.headers.retain(|(k, _)| k != "x-remove-me");
539                mutation.body = bytes::Bytes::from(format!("{scheme}://{authority}"));
540            })),
541            ..Default::default()
542        });
543
544        assert!(acl.has_modify_request());
545        assert!(acl.has_modify_response());
546
547        let mut request_mutation = super::RequestMutation::default();
548        acl.modify_request("https", &"example.com".into(), &mut request_mutation);
549        assert_eq!(
550            request_mutation.headers,
551            vec![("x-injected".to_string(), "https://example.com".to_string())]
552        );
553        assert_eq!(
554            request_mutation.body,
555            Some(bytes::Bytes::from_static(b"replaced"))
556        );
557
558        let mut response_mutation = super::ResponseMutation {
559            status: 200,
560            headers: vec![
561                ("x-remove-me".to_string(), "yes".to_string()),
562                ("x-keep-me".to_string(), "yes".to_string()),
563            ],
564            body: bytes::Bytes::new(),
565        };
566        acl.modify_response("https", &"example.com".into(), &mut response_mutation);
567        assert_eq!(response_mutation.status, 201);
568        assert_eq!(
569            response_mutation.headers,
570            vec![("x-keep-me".to_string(), "yes".to_string())]
571        );
572        assert_eq!(response_mutation.body, "https://example.com");
573    }
574}