Expand description
§http-acl
An ACL for HTTP requests.
§Why?
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.
§What it checks
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, and other special-use ranges) are denied outright regardless of the IP allow/deny lists, unless you opt in with .non_global_ip_ranges(true) on the builder.
§Usage
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(())
}§Wildcard hosts
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.
§Static DNS mappings
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.
§Modifying requests and responses
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.
§Integrating with a different HTTP client
http-acl is client-agnostic: it only classifies requests, it never makes them or touches the network itself. http-acl-reqwest is one integration, for reqwest; wiring up another client (hyper, ureq, isahc, or your own) means covering the same three places yourself.
1. Before the request is sent, check whatever you can read off the request as built:
use http_acl::{
HttpAcl,
utils::authority::{Authority, Host},
};
fn check_request(
acl: &HttpAcl,
scheme: &str,
method: &str,
authority: &str,
headers: &[(&str, &str)],
url_path: &str,
body: Option<&[u8]>,
) -> Result<(), String> {
if acl.is_scheme_allowed(scheme).is_denied() {
return Err(format!("scheme {scheme} is denied"));
}
if acl.is_method_allowed(method).is_denied() {
return Err(format!("method {method} is denied"));
}
let authority = Authority::parse(authority).map_err(|_| "invalid host".to_string())?;
match &authority.host {
Host::Ip(ip) if acl.is_ip_allowed(ip).is_denied() => {
return Err(format!("ip {ip} is denied"));
}
Host::Domain(domain) if acl.is_host_allowed(domain).is_denied() => {
return Err(format!("host {domain} is denied"));
}
_ => {}
}
if acl.is_port_allowed(authority.port).is_denied() {
return Err(format!("port {} is denied", authority.port));
}
for (name, value) in headers.iter().copied() {
if acl.is_header_allowed(name, value).is_denied() {
return Err(format!("header {name} is denied"));
}
}
if acl.is_url_path_allowed(url_path).is_denied() {
return Err(format!("path {url_path} is denied"));
}
if acl
.is_valid(scheme, &authority, headers.iter().copied(), body)
.is_denied()
{
return Err("request failed custom validation".to_string());
}
Ok(())
}
fn main() {
let acl = HttpAcl::builder()
.add_denied_host("example.com".to_string())
.unwrap()
.build();
let result = check_request(
&acl,
"https",
"GET",
"example.com:443",
&[("accept", "*/*")],
"/",
None,
);
assert!(result.is_err());
}is_url_path_allowed expects a percent-decoded path; most URL types (including url::Url, which both http-acl and reqwest use) return the path percent-encoded, so decode it first if it might contain encoded characters.
2. At DNS resolution, if your client lets you plug in a resolver, filter what it returns rather than trusting the host check in step 1 alone: a domain that passes is_host_allowed can still resolve to a denied or private IP, which is the classic SSRF vector this crate exists for. Check resolve_trusted_static_dns_mapping first and return it as-is if present, then resolve_static_dns_mapping and check it against is_ip_allowed/is_port_allowed, then fall back to real resolution and filter those results the same way. http-acl-reqwest’s HttpAclDnsResolver does exactly this and is a reasonable template to copy.
3. On redirects, a request to an allowed host can still be redirected to a denied one. If your client follows redirects internally, look for a hook to intercept each hop (like reqwest’s redirect::Policy::custom, which http-acl-reqwest’s HttpAclMiddleware::redirect_policy uses) and re-run the checks from step 1 against the new URL; otherwise disable automatic redirects and follow them yourself, checking each hop before you do.
A client that doesn’t give you a hook for one of these three only leaves you able to protect against what’s within reach of the other two.
§Documentation
See docs.rs.
Re-exports§
pub use acl::AclClassification;pub use acl::HttpAcl;pub use acl::HttpAclBuilder;pub use acl::HttpAclHooks;pub use acl::HttpRequestMethod;pub use acl::ValidateFn;pub use mutation::ModifyRequestFn;pub use mutation::ModifyResponseFn;pub use mutation::RequestMutation;pub use mutation::ResponseMutation;pub use utils::IntoIpRange;
Modules§
- acl
- Contains the
HttpAcl,HttpAclBuilder, and related types. - error
- Error types for the HTTP ACL library.
- mutation
- Contains the
ModifyRequestFn,ModifyResponseFn,RequestMutation, andResponseMutationtypes used to attach request/response mutation hooks to anHttpAcl. - utils
- Utility functions for the http-acl crate.
Enums§
- IpNet
- An IP network address, either IPv4 or IPv6.