An ACL for HTTP requests.
Systems which allow users to create arbitrary HTTP requests or specify arbitrary URLs to fetch like webhooks are vulnerable to SSRF attacks. An example is a malicious user could own a domain which resolves to a private IP address and then use that domain to make requests to internal services.
This crate provides a simple ACL to allow you to specify which hosts, ports, and IP ranges are allowed to be accessed. The ACL can then be used to ensure that the user's request meets the ACL's requirements before the request is made.
http-acl- the core ACL type, independent of any HTTP client. Start here, and see its README for how to integrate it with a client other than reqwest.http-acl-reqwest- middleware and a DNS resolver that enforce anhttp-aclACL on a reqwest client, including redirect targets.
An HttpAcl can check a request's scheme, method, host, port, IP, headers, and URL path, plus any custom logic you supply as a ValidateFn, and optionally mutate the request/response themselves via a ModifyRequestFn/ModifyResponseFn (see Modifying requests and responses below). Each of these (other than scheme, which is a simple allow/deny flag per protocol) is evaluated the same way: the allow list is checked first, then the deny list, and if neither matches, a per-category default decides the outcome. Methods, hosts, ports, and IPs deny by default; headers and URL paths allow by default. Every check returns an AclClassification rather than a plain boolean, so you can see why a request was allowed or denied, not just whether it was; call .is_allowed()/.is_denied() on it once only the outcome matters.
Non-global IP addresses (private, loopback, link-local, other special-use ranges, and the IPv6 ranges that embed an IPv4 address: ::/96, ::ffff:0:0/96, ::ffff:0:0:0/96, 64:ff9b::/96, 64:ff9b:1::/48 and 2002::/16) are denied outright regardless of the IP allow/deny lists, unless you opt in with .non_global_ip_ranges(true) on the builder.
use http_acl::{HttpAcl, IpNet};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create an HTTP ACL
let acl = HttpAcl::builder()
.add_allowed_host("example.com".to_string())
.unwrap()
.add_allowed_host("*.example.org".to_string())
.unwrap()
.add_denied_host("example.net".to_string())
.unwrap()
.add_allowed_port_range(8080..=8080)
.unwrap()
.add_denied_port_range(8443..=8443)
.unwrap()
.add_allowed_ip_range("1.0.0.0/8".parse::<IpNet>().unwrap())
.unwrap()
.add_denied_ip_range("9.0.0.0/8".parse::<IpNet>().unwrap())
.unwrap()
.build();
// Check if a request is allowed
assert!(acl.is_host_allowed("example.com").is_allowed());
assert!(acl.is_host_allowed("foo.example.org").is_allowed());
assert!(!acl.is_host_allowed("example.net").is_allowed());
assert!(acl.is_port_allowed(8080).is_allowed());
assert!(!acl.is_port_allowed(8443).is_allowed());
assert!(acl.is_ip_allowed(&"1.1.1.1".parse().unwrap()).is_allowed());
assert!(acl.is_ip_allowed(&"9.9.9.9".parse().unwrap()).is_denied());
assert!(acl
.is_ip_allowed(&"192.168.1.1".parse().unwrap())
.is_denied());
Ok(())
}Allowed/denied hosts may be exact hostnames or wildcard patterns, matched label-by-label:
?matches exactly one label (?.example.commatchesfoo.example.com, notfoo.bar.example.comor bareexample.com).*matches one or more labels (*.example.commatchesfoo.example.comandfoo.bar.example.com, but not bareexample.com).
A wildcard must occupy an entire label - foo*.example.com is rejected as an invalid pattern.
Allow-list entries (including wildcard ones) are always checked before deny-list entries, so a broad wildcard allow can shadow a more specific deny - e.g. allowing *.example.com while denying secret.example.com still allows secret.example.com, since the wildcard allow matches first.
A host can be pinned to a fixed address with add_static_dns_mapping, or add_trusted_static_dns_mapping if that address should bypass the IP/port ACL entirely. This is enforced by whichever DNS resolver a consuming crate wires up (e.g. http-acl-reqwest's); http-acl itself only holds the mappings and exposes resolve_static_dns_mapping/resolve_trusted_static_dns_mapping for a consumer to look them up.
Beyond checking a request, an HttpAcl can also carry a ModifyRequestFn and a ModifyResponseFn. Neither is about allow/deny - they run after the ACL has already decided a request is allowed, and let you rewrite it (or its response) instead. Typical uses:
- Injecting secrets. Attach an API key, bearer token, or signature header to every outgoing request without the caller having to know about it or being able to see, override, or leak it themselves - handy when the request is built from untrusted input (e.g. a user-supplied webhook URL) but the credential belongs to your service, not the caller.
- Adding operational headers. Stamp a request ID, tracing header, or
User-Agentonto every outgoing request centrally, rather than at every call site. - Sanitising requests before they leave. Strip a header a caller set that shouldn't reach the destination (distinct from denying the header outright via
add_denied_header, which rejects the whole request - aModifyRequestFnlets the request through with the header quietly removed instead). - Redacting responses. Strip
Set-Cookieor other sensitive response headers before they reach application code, or scrub a field out of a JSON response body, so a caller several layers away from the network call never sees it. - Normalising responses. Rewrite an upstream's non-standard status code or error body into something the rest of your application expects, in one place rather than at every call site.
Both hooks are attached the same way as ValidateFn - via HttpAclBuilder::build_full/try_build_full's HttpAclHooks argument, not a dedicated setter - since they're typically closures capturing state from outside the builder (a secret, a request-scoped ID generator, and so on):
use http_acl::{HttpAcl, HttpAclHooks};
use std::sync::Arc;
let api_key = "super-secret-api-key".to_string();
let acl = HttpAcl::builder().build_full(HttpAclHooks {
// Inject a secret the caller never sees or controls.
modify_request_fn: Some(Arc::new(move |_scheme, _authority, mutation| {
mutation
.headers
.push(("x-api-key".to_string(), api_key.clone()));
})),
// Strip a header before the caller ever sees the response.
modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
mutation.headers.retain(|(name, _)| name != "set-cookie");
})),
..Default::default()
});Check HttpAcl::has_modify_request/has_modify_response before doing any work to make a request/response available for mutation (e.g. buffering a body): a consuming crate should skip that work entirely when the corresponding hook isn't configured, so the feature costs nothing unless you use it. http-acl-reqwest does exactly this - see its README for the concrete performance trade-off of enabling ModifyResponseFn, and for more end-to-end examples.