Skip to main content

http_acl_reqwest/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::borrow::Cow;
5use std::future;
6use std::net::{SocketAddr, ToSocketAddrs};
7use std::str::FromStr;
8use std::sync::Arc;
9
10use anyhow::anyhow;
11use bytes::Bytes;
12use http::Extensions;
13use http::header::{HeaderName, HeaderValue};
14use http_acl::utils::authority::{Authority, Host};
15use reqwest::{
16    Body, Request, Response, ResponseBuilderExt,
17    dns::{Name, Resolve, Resolving},
18    redirect,
19};
20use reqwest_middleware::{Error, Middleware, Next};
21use thiserror::Error;
22
23pub use http_acl::{
24    self, HttpAcl, HttpAclBuilder, HttpAclHooks, ModifyRequestFn, ModifyResponseFn,
25    RequestMutation, ResponseMutation, ValidateFn,
26};
27
28#[derive(Debug, Clone)]
29/// A reqwest middleware that enforces an [`HttpAcl`].
30///
31/// On each request, checks (in order) the scheme, method, host or IP, port, headers,
32/// URL path, and finally any custom `ValidateFn`, returning
33/// [`reqwest_middleware::Error::Middleware`] on the first denial. This alone only
34/// covers the request as originally built: attach [`Self::dns_resolver`] to the
35/// `Client` as well, so domains are checked against the ACL as they resolve, and
36/// [`Self::redirect_policy`], so redirect targets are checked too. See the crate-level
37/// documentation for a full example wiring all three together.
38///
39/// If the `HttpAcl` has a `ModifyRequestFn` and/or `ModifyResponseFn` attached (see
40/// `HttpAclHooks`), those run too - a `ModifyRequestFn` once all the checks above
41/// pass and just before the request is sent, and a `ModifyResponseFn` on the way
42/// back, before the caller ever sees the `Response`. Neither is applied when not
43/// configured - see `HttpAcl::has_modify_request`/`has_modify_response`. A
44/// configured `ModifyResponseFn` forces the whole response body to be buffered and
45/// the response rebuilt from scratch; see the crate README for the performance
46/// trade-off.
47///
48/// Header values that are not valid UTF-8 (`obs-text` bytes 0x80-0xFF) are not
49/// refused. The header ACL compares them as raw bytes
50/// (`HttpAcl::is_header_allowed_bytes`); a `ValidateFn` and the mutation hooks see
51/// them lossily decoded (U+FFFD), and a value a hook leaves unchanged is sent or
52/// returned with its original bytes.
53pub struct HttpAclMiddleware {
54    acl: Arc<HttpAcl>,
55}
56
57impl HttpAclMiddleware {
58    /// Create a new HTTP ACL middleware from an already-built [`HttpAcl`].
59    pub fn new(acl: HttpAcl) -> Self {
60        Self { acl: Arc::new(acl) }
61    }
62
63    /// Get the [`HttpAcl`] this middleware enforces.
64    pub fn acl(&self) -> Arc<HttpAcl> {
65        self.acl.clone()
66    }
67
68    /// Create a DNS resolver that enforces the ACL, using `getaddrinfo` to actually
69    /// resolve hostnames.
70    ///
71    /// Set via `Client::builder().dns_resolver(...)`. Without this, a domain that
72    /// resolves to a denied or non-global IP (the classic SSRF vector) is never
73    /// checked, since [`HttpAclMiddleware`] only ever sees the request as built, not
74    /// the address it eventually connects to.
75    pub fn dns_resolver(&self) -> Arc<HttpAclDnsResolver> {
76        Arc::new(HttpAclDnsResolver::new(self))
77    }
78
79    /// Same as [`Self::dns_resolver`], but delegating actual resolution to a custom
80    /// [`Resolve`] implementation instead of `getaddrinfo`.
81    pub fn with_dns_resolver(&self, dns_resolver: Arc<dyn Resolve>) -> Arc<HttpAclDnsResolver> {
82        Arc::new(HttpAclDnsResolver::with_dns_resolver(self, dns_resolver))
83    }
84
85    /// Create a [`redirect::Policy`] that enforces the ACL on every redirect hop.
86    ///
87    /// # Why this is necessary
88    ///
89    /// `HttpAclMiddleware` only validates the request it is given. By default `reqwest`
90    /// follows HTTP redirects internally (up to 10 hops) before control ever returns to
91    /// the middleware chain, so a server an allowed host redirects to - e.g. a `302` to
92    /// `http://169.254.169.254/` - is never re-checked against the ACL. Set this policy
93    /// on the `Client` (in addition to [`Self::dns_resolver`]) to close that gap:
94    ///
95    /// ```no_run
96    /// # use http_acl_reqwest::HttpAclMiddleware;
97    /// # use http_acl::HttpAcl;
98    /// # let middleware = HttpAclMiddleware::new(HttpAcl::builder().build());
99    /// let client = reqwest::Client::builder()
100    ///     .dns_resolver(middleware.dns_resolver())
101    ///     .redirect(middleware.redirect_policy())
102    ///     .build()
103    ///     .unwrap();
104    /// ```
105    ///
106    /// Uses a maximum of 10 redirects, matching `reqwest`'s own default. Use
107    /// [`Self::redirect_policy_with_max`] to customise this.
108    ///
109    /// # Limitations
110    ///
111    /// Only the scheme, host/IP, port, and URL path of each redirect target can be
112    /// checked this way - `reqwest`'s redirect policy does not expose the headers or
113    /// body of the redirected request, so denied headers, denied bodies, and any custom
114    /// `validate_fn` are not re-evaluated per hop. `ModifyResponseFn` only ever sees
115    /// the final response of a redirect chain, never an intermediate `3xx`, for the
116    /// same reason.
117    ///
118    /// `ModifyRequestFn` is different: it's called once, against the original
119    /// outgoing request, before `Middleware::handle` hands it to `reqwest`, but a
120    /// header it injects is - like any header set before `send()` - carried forward
121    /// by `reqwest`'s own redirect handling to every subsequent hop (`reqwest` may
122    /// still strip specific headers, e.g. `Authorization`, when a redirect crosses
123    /// origins). So an injected header reaches the whole chain even though the
124    /// closure itself does not run again.
125    pub fn redirect_policy(&self) -> redirect::Policy {
126        self.redirect_policy_with_max(10)
127    }
128
129    /// Same as [`Self::redirect_policy`], but with a custom maximum number of redirects.
130    pub fn redirect_policy_with_max(&self, max_redirects: usize) -> redirect::Policy {
131        let acl = self.acl.clone();
132        redirect::Policy::custom(move |attempt| {
133            // `Attempt::error`/`follow`/`stop` consume `attempt` by value, so the denial
134            // reason (if any) is computed into an owned `String` first, in its own scope,
135            // to release the borrow of `attempt` held by `attempt.url()`/`attempt.previous()`.
136            let deny_reason = 'reason: {
137                if attempt.previous().len() > max_redirects {
138                    break 'reason Some("too many redirects".to_string());
139                }
140
141                let url = attempt.url();
142
143                let scheme = url.scheme();
144                if acl.is_scheme_allowed(scheme).is_denied() {
145                    break 'reason Some(format!("scheme {scheme} is denied"));
146                }
147
148                let Some(host) = url.host() else {
149                    break 'reason Some("missing host".to_string());
150                };
151
152                match host {
153                    url::Host::Domain(domain) => {
154                        if acl.is_host_allowed(domain).is_denied() {
155                            break 'reason Some(format!("host {domain} is denied"));
156                        }
157                    }
158                    url::Host::Ipv4(ip) => {
159                        let ip = std::net::IpAddr::V4(ip);
160                        if acl.is_ip_allowed(&ip).is_denied() {
161                            break 'reason Some(format!("ip {ip} is denied"));
162                        }
163                    }
164                    url::Host::Ipv6(ip) => {
165                        let ip = std::net::IpAddr::V6(ip);
166                        if acl.is_ip_allowed(&ip).is_denied() {
167                            break 'reason Some(format!("ip {ip} is denied"));
168                        }
169                    }
170                }
171
172                if let Some(port) = url.port_or_known_default()
173                    && acl.is_port_allowed(port).is_denied()
174                {
175                    break 'reason Some(format!("port {port} is denied"));
176                }
177
178                // `Url::path()` is percent-encoded; `is_url_path_allowed` expects a
179                // decoded path.
180                match percent_encoding::percent_decode_str(url.path()).decode_utf8() {
181                    Ok(path) => {
182                        if acl.is_url_path_allowed(&path).is_denied() {
183                            break 'reason Some(format!("path {path} is denied"));
184                        }
185                    }
186                    Err(_) => break 'reason Some("invalid URL path encoding".to_string()),
187                }
188
189                None
190            };
191
192            match deny_reason {
193                Some(reason) => attempt.error(std::io::Error::other(reason)),
194                None => attempt.follow(),
195            }
196        })
197    }
198}
199
200#[async_trait::async_trait]
201impl Middleware for HttpAclMiddleware {
202    async fn handle(
203        &self,
204        mut req: Request,
205        extensions: &mut Extensions,
206        next: Next<'_>,
207    ) -> std::result::Result<Response, Error> {
208        // Owned, rather than borrowed from `req.url()`, since it's still needed
209        // after `req` is moved into `next.run(...)` below (to check the response
210        // against `ModifyResponseFn`).
211        let scheme = req.url().scheme().to_string();
212        let acl_scheme_match = self.acl.is_scheme_allowed(&scheme);
213        if acl_scheme_match.is_denied() {
214            return Err(Error::Middleware(anyhow!(
215                "scheme {} is denied - {}",
216                scheme,
217                acl_scheme_match
218            )));
219        }
220
221        let method = req.method().as_str();
222        let acl_method_match = self.acl.is_method_allowed(method);
223        if acl_method_match.is_denied() {
224            return Err(Error::Middleware(anyhow!(
225                "method {} is denied - {}",
226                method,
227                acl_method_match
228            )));
229        }
230
231        if let Some(host) = req.url().host_str() {
232            let authority = Authority::parse(host)
233                .map_err(|_| Error::Middleware(anyhow!("invalid host: {}", host)))?;
234
235            match &authority.host {
236                Host::Ip(ip) => {
237                    let acl_ip_match = self.acl.is_ip_allowed(ip);
238                    if acl_ip_match.is_denied() {
239                        return Err(Error::Middleware(anyhow!(
240                            "ip {} is denied - {}",
241                            ip,
242                            acl_ip_match
243                        )));
244                    }
245                }
246                Host::Domain(domain) => {
247                    let acl_host_match = self.acl.is_host_allowed(domain);
248                    if acl_host_match.is_denied() {
249                        return Err(Error::Middleware(anyhow!(
250                            "host {} is denied - {}",
251                            domain,
252                            acl_host_match
253                        )));
254                    }
255                }
256            }
257
258            if let Some(port) = req.url().port_or_known_default() {
259                let acl_port_match = self.acl.is_port_allowed(port);
260                if acl_port_match.is_denied() {
261                    return Err(Error::Middleware(anyhow!(
262                        "port {} is denied - {}",
263                        port,
264                        acl_port_match
265                    )));
266                }
267            }
268
269            // Header values may carry obs-text bytes (0x80-0xFF) that are not valid
270            // UTF-8, so they are checked as raw bytes rather than refused.
271            for (key, value) in req.headers() {
272                let header_name = key.as_str();
273                let acl_header_match = self
274                    .acl
275                    .is_header_allowed_bytes(header_name, value.as_bytes());
276                if acl_header_match.is_denied() {
277                    return Err(Error::Middleware(anyhow!(
278                        "header {}: {} is denied - {}",
279                        header_name,
280                        header_value_lossy(value),
281                        acl_header_match
282                    )));
283                }
284            }
285
286            // `Url::path()` is percent-encoded; `is_url_path_allowed` expects a
287            // decoded path.
288            let url_path = percent_encoding::percent_decode_str(req.url().path())
289                .decode_utf8()
290                .map_err(|_| Error::Middleware(anyhow!("invalid URL path encoding")))?;
291            let acl_url_path_match = self.acl.is_url_path_allowed(&url_path);
292            if acl_url_path_match.is_denied() {
293                return Err(Error::Middleware(anyhow!(
294                    "path {} is denied - {}",
295                    url_path,
296                    acl_url_path_match
297                )));
298            }
299
300            // Every header reaches the `ValidateFn`, a non-UTF-8 value lossily
301            // decoded, so a custom check keyed on a header cannot be bypassed by
302            // putting obs-text bytes in it.
303            let lossy_headers: Vec<(&str, Cow<'_, str>)> = req
304                .headers()
305                .iter()
306                .map(|(k, v)| (k.as_str(), header_value_lossy(v)))
307                .collect();
308            let valid_match = self.acl.is_valid(
309                &scheme,
310                &authority,
311                lossy_headers.iter().map(|(k, v)| (*k, v.as_ref())),
312                req.body().and_then(|b| b.as_bytes()),
313            );
314            if valid_match.is_denied() {
315                return Err(Error::Middleware(anyhow!(
316                    "request is denied - {}",
317                    valid_match
318                )));
319            }
320
321            if self.acl.has_modify_request() {
322                let (headers, mut raw_headers) = mutation_headers(req.headers());
323                let mut mutation = RequestMutation {
324                    headers,
325                    body: req
326                        .body()
327                        .and_then(|b| b.as_bytes())
328                        .map(Bytes::copy_from_slice),
329                };
330                self.acl.modify_request(&scheme, &authority, &mut mutation);
331
332                req.headers_mut().clear();
333                for (name, value) in &mutation.headers {
334                    let header_name = HeaderName::from_str(name).map_err(|e| {
335                        Error::Middleware(anyhow!("invalid header name `{name}`: {e}"))
336                    })?;
337                    let header_value = match take_raw_header(&mut raw_headers, name, value) {
338                        Some(raw) => raw,
339                        None => HeaderValue::from_str(value).map_err(|e| {
340                            Error::Middleware(anyhow!("invalid header value for `{name}`: {e}"))
341                        })?,
342                    };
343                    req.headers_mut().append(header_name, header_value);
344                }
345                if let Some(body) = mutation.body {
346                    *req.body_mut() = Some(Body::from(body));
347                }
348            }
349
350            let mut res = next.run(req, extensions).await?;
351
352            if self.acl.has_modify_response() {
353                let status = res.status();
354                let version = res.version();
355                let url = res.url().clone();
356                let extensions_snapshot = res.extensions().clone();
357                let (headers, mut raw_headers) = mutation_headers(res.headers());
358                // The point where the whole response body is buffered into memory -
359                // only reached when a `ModifyResponseFn` is actually configured.
360                let body = res.bytes().await?;
361
362                let mut mutation = ResponseMutation {
363                    status: status.as_u16(),
364                    headers,
365                    body,
366                };
367                self.acl.modify_response(&scheme, &authority, &mut mutation);
368
369                let mut builder = http::Response::builder()
370                    .status(mutation.status)
371                    .version(version);
372                for (name, value) in &mutation.headers {
373                    builder = match take_raw_header(&mut raw_headers, name, value) {
374                        Some(raw) => builder.header(name.as_str(), raw),
375                        None => builder.header(name.as_str(), value.as_str()),
376                    };
377                }
378                // `Response`'s own `Extensions` (distinct from the `extensions`
379                // parameter of this function) carries things like `HttpInfo`
380                // (backing `Response::remote_addr()`) - restore it here, before
381                // `.url(...)`, not after: `.url()` inserts its own extension entry,
382                // and a wholesale `*extensions_mut() = ...` afterwards would
383                // clobber that again.
384                if let Some(ext) = builder.extensions_mut() {
385                    *ext = extensions_snapshot;
386                }
387                let http_response = builder
388                    .url(url)
389                    .body(mutation.body)
390                    .map_err(|e| Error::Middleware(anyhow!("failed to rebuild response: {e}")))?;
391                res = Response::from(http_response);
392            }
393
394            Ok(res)
395        } else {
396            return Err(Error::Middleware(anyhow!("missing host")));
397        }
398    }
399}
400
401/// Decodes a header value for the `&str`-based ACL hooks: exact for UTF-8, lossy
402/// (U+FFFD) for obs-text bytes that are not valid UTF-8.
403fn header_value_lossy(value: &HeaderValue) -> Cow<'_, str> {
404    String::from_utf8_lossy(value.as_bytes())
405}
406
407/// A header whose value is not valid UTF-8: its name, its lossy decode as handed
408/// to a mutation hook, and its original bytes.
409type RawHeader = (String, String, HeaderValue);
410
411/// Builds the `(name, value)` list handed to a mutation hook, plus the original
412/// bytes of every value that could only be decoded lossily, so a header the hook
413/// leaves untouched goes back out with its bytes unchanged rather than dropped or
414/// rewritten as U+FFFD.
415fn mutation_headers(headers: &http::HeaderMap) -> (Vec<(String, String)>, Vec<RawHeader>) {
416    let mut raw = Vec::new();
417    let list = headers
418        .iter()
419        .map(|(k, v)| {
420            let name = k.as_str().to_string();
421            let value = header_value_lossy(v).into_owned();
422            if std::str::from_utf8(v.as_bytes()).is_err() {
423                raw.push((name.clone(), value.clone(), v.clone()));
424            }
425            (name, value)
426        })
427        .collect();
428    (list, raw)
429}
430
431/// Returns (and consumes) the original bytes of a header the hook left as it was
432/// given, matched on name and lossy value.
433fn take_raw_header(raw: &mut Vec<RawHeader>, name: &str, value: &str) -> Option<HeaderValue> {
434    let index = raw
435        .iter()
436        .position(|(n, v, _)| n.eq_ignore_ascii_case(name) && v == value)?;
437    Some(raw.remove(index).2)
438}
439
440type BoxError = Box<dyn std::error::Error + Send + Sync>;
441
442struct GaiResolver;
443
444impl Resolve for GaiResolver {
445    fn resolve(&self, name: Name) -> Resolving {
446        Box::pin(async move {
447            // `Name` is a bare hostname with no port, so `ToSocketAddrs` must be given one
448            // explicitly (e.g. via a tuple) - calling it on the string directly always fails.
449            let addresses = (name.as_str(), 0)
450                .to_socket_addrs()
451                .map_err(|e| Box::new(e) as BoxError)?;
452            Ok(Box::new(addresses.into_iter()) as Box<dyn Iterator<Item = SocketAddr> + Send>)
453        })
454    }
455}
456
457/// A [`Resolve`]r that checks each resolved address against an [`HttpAcl`] before
458/// handing it back to `reqwest`.
459///
460/// Denies the hostname itself first via [`HttpAcl::is_host_allowed`]. For the
461/// addresses it resolves to, a trusted static DNS mapping (see
462/// [`HttpAclBuilder::add_trusted_static_dns_mapping`]) is returned as-is; a regular
463/// static mapping or a genuinely resolved address is
464/// filtered through [`HttpAcl::is_ip_allowed`] and [`HttpAcl::is_port_allowed`], so
465/// only addresses the ACL permits are ever handed to `reqwest`. An address with port 0
466/// skips the port check, since the connector gives it the URL's port, which the
467/// middleware checks. Constructed via
468/// [`HttpAclMiddleware::dns_resolver`] or [`HttpAclMiddleware::with_dns_resolver`],
469/// not directly.
470pub struct HttpAclDnsResolver {
471    dns_resolver: Arc<dyn Resolve>,
472    acl: Arc<HttpAcl>,
473}
474
475impl HttpAclDnsResolver {
476    /// Create a new ACL resolver that resolves hostnames via `getaddrinfo`.
477    pub fn new(middleware: &HttpAclMiddleware) -> Self {
478        Self {
479            dns_resolver: Arc::new(GaiResolver),
480            acl: middleware.acl(),
481        }
482    }
483
484    /// Create a new ACL resolver that delegates actual resolution to a custom
485    /// [`Resolve`] implementation.
486    pub fn with_dns_resolver(
487        middleware: &HttpAclMiddleware,
488        dns_resolver: Arc<dyn Resolve>,
489    ) -> Self {
490        Self {
491            dns_resolver,
492            acl: middleware.acl(),
493        }
494    }
495}
496
497impl Resolve for HttpAclDnsResolver {
498    fn resolve(&self, name: Name) -> Resolving {
499        if self.acl.is_host_allowed(name.as_str()).is_denied() {
500            let err: BoxError = Box::new(HttpAclError::HostDenied {
501                host: name.as_str().to_string(),
502            });
503            return Box::pin(future::ready(Err(err)));
504        }
505
506        let acl = self.acl.clone();
507        let resolver = self.dns_resolver.clone();
508
509        Box::pin(async move {
510            if let Some(tcp_address) = acl.resolve_trusted_static_dns_mapping(name.as_str()) {
511                // Trusted mappings intentionally bypass the IP/port ACL - the caller
512                // vouches for this destination (e.g. pinning a hostname to an internal
513                // address on purpose).
514                Ok(Box::new(std::iter::once(tcp_address))
515                    as Box<dyn Iterator<Item = SocketAddr> + Send>)
516            } else if let Some(tcp_address) = acl.resolve_static_dns_mapping(name.as_str()) {
517                // Regular static mappings must still pass the IP/port ACL, just like
518                // resolved addresses do below - otherwise they'd be a way to bypass it
519                // entirely (e.g. mapping a host to a private IP while non-global IPs
520                // are denied).
521                if acl.is_ip_allowed(&tcp_address.ip()).is_allowed()
522                    && is_resolved_port_allowed(&acl, tcp_address.port())
523                {
524                    Ok(Box::new(std::iter::once(tcp_address))
525                        as Box<dyn Iterator<Item = SocketAddr> + Send>)
526                } else {
527                    let err: BoxError =
528                        Box::new(std::io::Error::other("Static DNS mapping denied by ACL"));
529                    Err(err)
530                }
531            } else {
532                let resolved = resolver.resolve(name).await;
533                match resolved {
534                    Ok(addresses) => {
535                        let filtered = addresses
536                            .into_iter()
537                            .filter(|addr| {
538                                acl.is_ip_allowed(&addr.ip()).is_allowed()
539                                    && is_resolved_port_allowed(&acl, addr.port())
540                            })
541                            .collect::<Vec<_>>();
542                        Ok(Box::new(filtered.into_iter())
543                            as Box<dyn Iterator<Item = SocketAddr> + Send>)
544                    }
545                    Err(e) => Err(e),
546                }
547            }
548        })
549    }
550}
551
552/// Checks the port of a resolved address. Port 0, which every `getaddrinfo` answer
553/// carries, is not checked: the connector replaces it with the URL's port, and the
554/// middleware checks that one. A non-zero port is checked, because the connector uses it
555/// when the URL names no port of its own (hisn-io/hisn#2821).
556fn is_resolved_port_allowed(acl: &HttpAcl, port: u16) -> bool {
557    port == 0 || acl.is_port_allowed(port).is_allowed()
558}
559
560#[derive(Error, Debug)]
561/// An error that can occur when resolving a host.
562///
563/// Returned by [`HttpAclDnsResolver`] when a hostname itself is denied by the ACL.
564/// Downcast the boxed error from a failed resolution to check for this specifically,
565/// as opposed to a lower-level resolution failure.
566pub enum HttpAclError {
567    /// Host resolution denied by ACL.
568    #[error("Host resolution denied by ACL: {host}")]
569    HostDenied {
570        /// The host that was denied.
571        host: String,
572    },
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use http_acl::acl::AclClassification;
579    use reqwest_middleware::ClientWithMiddleware;
580
581    #[tokio::test]
582    async fn test_http_acl_middleware() {
583        let acl = HttpAcl::builder()
584            .add_denied_host("example.com".to_string())
585            .unwrap()
586            .build();
587
588        let middleware = HttpAclMiddleware::new(acl);
589
590        let client = reqwest_middleware::ClientBuilder::new(
591            reqwest::Client::builder()
592                .dns_resolver(middleware.dns_resolver())
593                .build()
594                .unwrap(),
595        )
596        .with(middleware)
597        .build();
598
599        let request = client.get("http://example.com/").send().await;
600
601        assert!(request.is_err());
602        assert_eq!(
603            request.unwrap_err().to_string(),
604            "host example.com is denied - The entity is denied according to the denied ACL."
605        );
606    }
607
608    #[tokio::test]
609    async fn test_middleware_decodes_percent_encoded_path() {
610        let acl = HttpAcl::builder()
611            .add_allowed_host("example.com".to_string())
612            .unwrap()
613            .add_denied_url_path("/secret file".to_string())
614            .unwrap()
615            .build();
616
617        let middleware = HttpAclMiddleware::new(acl);
618
619        let client =
620            reqwest_middleware::ClientBuilder::new(reqwest::Client::builder().build().unwrap())
621                .with(middleware)
622                .build();
623
624        // Regression test: `Url::path()` is percent-encoded ("%20" for the space
625        // here), but `is_url_path_allowed` matches against the decoded path, so the
626        // middleware must decode before checking - otherwise this denied path would
627        // never match and the request would go through.
628        let request = client.get("http://example.com/secret%20file").send().await;
629
630        assert!(request.is_err());
631        assert!(
632            request
633                .unwrap_err()
634                .to_string()
635                .contains("path /secret file is denied")
636        );
637    }
638
639    #[tokio::test]
640    async fn test_dns_resolver_returns_typed_error_for_denied_host() {
641        let acl = HttpAcl::builder()
642            .add_denied_host("denied.example.com".to_string())
643            .unwrap()
644            .build();
645
646        let middleware = HttpAclMiddleware::new(acl);
647        let resolver = middleware.dns_resolver();
648
649        let name: reqwest::dns::Name = "denied.example.com".parse().unwrap();
650        let err = match resolver.resolve(name).await {
651            Ok(_) => panic!("expected resolution to be denied"),
652            Err(e) => e,
653        };
654
655        let acl_err = err
656            .downcast_ref::<HttpAclError>()
657            .expect("expected a HttpAclError");
658        assert!(matches!(
659            acl_err,
660            HttpAclError::HostDenied { host } if host == "denied.example.com"
661        ));
662    }
663
664    /// Answers every name with fixed addresses, as a stub for `getaddrinfo`.
665    struct FixedResolver(Vec<SocketAddr>);
666
667    impl Resolve for FixedResolver {
668        fn resolve(&self, _name: Name) -> Resolving {
669            let addresses = self.0.clone();
670            Box::pin(async move {
671                Ok(Box::new(addresses.into_iter()) as Box<dyn Iterator<Item = SocketAddr> + Send>)
672            })
673        }
674    }
675
676    #[tokio::test]
677    async fn test_dns_resolver_keeps_port_zero_answers() {
678        // hisn-io/hisn#2794, #2821: `getaddrinfo` answers carry port 0, and the connector
679        // replaces port 0 with the URL's port, which the middleware checks. Checking port 0
680        // against an ACL that allows only 80 and 443 dropped every resolved address, so no
681        // hostname could be fetched.
682        let acl = HttpAcl::builder()
683            .ip_acl_default(true)
684            .host_acl_default(true)
685            .build();
686        let middleware = HttpAclMiddleware::new(acl);
687        let resolver = middleware.with_dns_resolver(Arc::new(FixedResolver(vec![
688            "1.1.1.1:0".parse().unwrap(),
689            "1.1.1.1:22".parse().unwrap(),
690            "[64:ff9b::7f00:1]:0".parse().unwrap(),
691        ])));
692
693        let name: Name = "jwks.example.com".parse().unwrap();
694        let addresses = match resolver.resolve(name).await {
695            Ok(addresses) => addresses.collect::<Vec<_>>(),
696            Err(e) => panic!("expected resolution to succeed: {e}"),
697        };
698
699        // A non-zero port is the one the connector uses when the URL has none, so it is
700        // still checked; a denied IP is dropped whatever its port.
701        assert_eq!(addresses, vec!["1.1.1.1:0".parse::<SocketAddr>().unwrap()]);
702    }
703
704    #[tokio::test]
705    async fn test_dns_resolver_resolves_hostnames() {
706        use tokio::io::{AsyncReadExt, AsyncWriteExt};
707        use tokio::net::TcpListener;
708
709        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
710        let addr = listener.local_addr().unwrap();
711
712        tokio::spawn(async move {
713            if let Ok((mut socket, _)) = listener.accept().await {
714                let mut buf = [0u8; 1024];
715                let _ = socket.read(&mut buf).await;
716                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
717                let _ = socket.write_all(response.as_bytes()).await;
718            }
719        });
720
721        let acl = HttpAcl::builder()
722            .non_global_ip_ranges(true)
723            .ip_acl_default(true)
724            .port_acl_default(true)
725            .host_acl_default(true)
726            .build();
727
728        let middleware = HttpAclMiddleware::new(acl);
729
730        let client = reqwest_middleware::ClientBuilder::new(
731            reqwest::Client::builder()
732                .dns_resolver(middleware.dns_resolver())
733                .build()
734                .unwrap(),
735        )
736        .with(middleware)
737        .build();
738
739        // Regression test: the default `GaiResolver` used to call `to_socket_addrs()` on a
740        // bare hostname (no port), which always errors, so *no* hostname could ever resolve.
741        let request = client
742            .get(format!("http://localhost:{}/", addr.port()))
743            .send()
744            .await;
745
746        assert!(request.is_ok(), "{:?}", request.err());
747    }
748
749    #[tokio::test]
750    async fn test_trusted_static_dns_mapping_bypasses_ip_port_acl() {
751        use tokio::io::{AsyncReadExt, AsyncWriteExt};
752        use tokio::net::TcpListener;
753
754        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
755        let addr = listener.local_addr().unwrap();
756
757        tokio::spawn(async move {
758            if let Ok((mut socket, _)) = listener.accept().await {
759                let mut buf = [0u8; 1024];
760                let _ = socket.read(&mut buf).await;
761                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
762                let _ = socket.write_all(response.as_bytes()).await;
763            }
764        });
765
766        // Deny everything at the IP/port level (the default), but pin "trusted.internal"
767        // to our mock server via a *trusted* static mapping, which should bypass that.
768        let acl = HttpAcl::builder()
769            .host_acl_default(true)
770            .add_trusted_static_dns_mapping("trusted.internal".to_string(), addr)
771            .unwrap()
772            .build();
773
774        assert!(acl.is_ip_allowed(&addr.ip()).is_denied());
775        assert!(acl.is_port_allowed(addr.port()).is_denied());
776
777        let middleware = HttpAclMiddleware::new(acl);
778
779        let client = reqwest_middleware::ClientBuilder::new(
780            reqwest::Client::builder()
781                .dns_resolver(middleware.dns_resolver())
782                .build()
783                .unwrap(),
784        )
785        .with(middleware)
786        .build();
787
788        // No explicit port in the URL - the connector must pick up the trusted
789        // mapping's port, proving both the IP and port ACL were bypassed for it.
790        let request = client.get("http://trusted.internal/").send().await;
791
792        assert!(request.is_ok(), "{:?}", request.err());
793    }
794
795    #[tokio::test]
796    async fn test_redirect_policy_blocks_disallowed_target() {
797        use tokio::io::{AsyncReadExt, AsyncWriteExt};
798        use tokio::net::TcpListener;
799
800        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
801        let addr = listener.local_addr().unwrap();
802
803        tokio::spawn(async move {
804            if let Ok((mut socket, _)) = listener.accept().await {
805                let mut buf = [0u8; 1024];
806                let _ = socket.read(&mut buf).await;
807                let response = "HTTP/1.1 302 Found\r\nLocation: http://192.168.1.1/\r\nContent-Length: 0\r\n\r\n";
808                let _ = socket.write_all(response.as_bytes()).await;
809            }
810        });
811
812        // Allow everything except one specific (non-global) IP, so the initial request to
813        // our local mock server succeeds but the redirect target is denied.
814        let acl = HttpAcl::builder()
815            .non_global_ip_ranges(true)
816            .ip_acl_default(true)
817            .port_acl_default(true)
818            .add_denied_ip_range((
819                "192.168.1.1".parse::<std::net::IpAddr>().unwrap(),
820                "192.168.1.1".parse::<std::net::IpAddr>().unwrap(),
821            ))
822            .unwrap()
823            .build();
824
825        let middleware = HttpAclMiddleware::new(acl);
826
827        let client = reqwest_middleware::ClientBuilder::new(
828            reqwest::Client::builder()
829                .dns_resolver(middleware.dns_resolver())
830                .redirect(middleware.redirect_policy())
831                .build()
832                .unwrap(),
833        )
834        .with(middleware)
835        .build();
836
837        let request = client
838            .get(format!("http://127.0.0.1:{}/", addr.port()))
839            .send()
840            .await;
841
842        assert!(request.is_err());
843    }
844
845    #[test]
846    fn test_no_hooks_configured_leaves_acl_unaffected() {
847        let acl = HttpAcl::builder().build();
848
849        assert!(!acl.has_modify_request());
850        assert!(!acl.has_modify_response());
851    }
852
853    #[tokio::test]
854    async fn test_modify_request_injects_header() {
855        use tokio::io::{AsyncReadExt, AsyncWriteExt};
856        use tokio::net::TcpListener;
857
858        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
859        let addr = listener.local_addr().unwrap();
860
861        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
862        tokio::spawn(async move {
863            if let Ok((mut socket, _)) = listener.accept().await {
864                let mut buf = [0u8; 4096];
865                let n = socket.read(&mut buf).await.unwrap_or(0);
866                let _ = captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
867                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
868                let _ = socket.write_all(response.as_bytes()).await;
869            }
870        });
871
872        let acl = HttpAcl::builder()
873            .non_global_ip_ranges(true)
874            .ip_acl_default(true)
875            .port_acl_default(true)
876            .host_acl_default(true)
877            .build_full(HttpAclHooks {
878                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
879                    mutation
880                        .headers
881                        .push(("x-injected-secret".to_string(), "sssh".to_string()));
882                })),
883                ..Default::default()
884            });
885
886        let middleware = HttpAclMiddleware::new(acl);
887
888        let client = reqwest_middleware::ClientBuilder::new(
889            reqwest::Client::builder()
890                .dns_resolver(middleware.dns_resolver())
891                .build()
892                .unwrap(),
893        )
894        .with(middleware)
895        .build();
896
897        let request = client
898            .get(format!("http://127.0.0.1:{}/", addr.port()))
899            .send()
900            .await;
901
902        assert!(request.is_ok(), "{:?}", request.err());
903        let captured = captured_rx.await.unwrap();
904        assert!(
905            captured.to_lowercase().contains("x-injected-secret: sssh"),
906            "captured request did not contain the injected header:\n{captured}"
907        );
908    }
909
910    #[tokio::test]
911    async fn test_modify_request_replaces_body_and_content_length() {
912        use tokio::io::{AsyncReadExt, AsyncWriteExt};
913        use tokio::net::TcpListener;
914
915        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
916        let addr = listener.local_addr().unwrap();
917
918        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
919        tokio::spawn(async move {
920            if let Ok((mut socket, _)) = listener.accept().await {
921                let mut buf = [0u8; 4096];
922                let n = socket.read(&mut buf).await.unwrap_or(0);
923                let _ = captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
924                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
925                let _ = socket.write_all(response.as_bytes()).await;
926            }
927        });
928
929        let new_body = "a much longer replacement body than the original";
930        let acl = HttpAcl::builder()
931            .non_global_ip_ranges(true)
932            .ip_acl_default(true)
933            .port_acl_default(true)
934            .host_acl_default(true)
935            .build_full(HttpAclHooks {
936                modify_request_fn: Some(Arc::new(move |_scheme, _authority, mutation| {
937                    mutation.body = Some(Bytes::from(new_body));
938                })),
939                ..Default::default()
940            });
941
942        let middleware = HttpAclMiddleware::new(acl);
943
944        let client = reqwest_middleware::ClientBuilder::new(
945            reqwest::Client::builder()
946                .dns_resolver(middleware.dns_resolver())
947                .build()
948                .unwrap(),
949        )
950        .with(middleware)
951        .build();
952
953        let request = client
954            .post(format!("http://127.0.0.1:{}/", addr.port()))
955            .body("short")
956            .send()
957            .await;
958
959        assert!(request.is_ok(), "{:?}", request.err());
960        let captured = captured_rx.await.unwrap();
961        assert!(
962            captured.contains(&format!("content-length: {}", new_body.len()))
963                || captured.contains(&format!("Content-Length: {}", new_body.len())),
964            "captured request did not have a Content-Length matching the replaced body:\n{captured}"
965        );
966        assert!(
967            captured.ends_with(new_body),
968            "captured request did not end with the replaced body:\n{captured}"
969        );
970    }
971
972    #[tokio::test]
973    async fn test_modify_response_rewrites_status_headers_and_body() {
974        use tokio::io::{AsyncReadExt, AsyncWriteExt};
975        use tokio::net::TcpListener;
976
977        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
978        let addr = listener.local_addr().unwrap();
979
980        tokio::spawn(async move {
981            if let Ok((mut socket, _)) = listener.accept().await {
982                let mut buf = [0u8; 1024];
983                let _ = socket.read(&mut buf).await;
984                let body = "original body";
985                let response = format!(
986                    "HTTP/1.1 200 OK\r\nx-remove-me: yes\r\nContent-Length: {}\r\n\r\n{}",
987                    body.len(),
988                    body
989                );
990                let _ = socket.write_all(response.as_bytes()).await;
991            }
992        });
993
994        let acl = HttpAcl::builder()
995            .non_global_ip_ranges(true)
996            .ip_acl_default(true)
997            .port_acl_default(true)
998            .host_acl_default(true)
999            .build_full(HttpAclHooks {
1000                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1001                    mutation.status = 201;
1002                    mutation.headers.retain(|(k, _)| k != "x-remove-me");
1003                    mutation
1004                        .headers
1005                        .push(("x-added".to_string(), "yes".to_string()));
1006                    mutation.body = Bytes::from_static(b"redacted body");
1007                })),
1008                ..Default::default()
1009            });
1010
1011        let middleware = HttpAclMiddleware::new(acl);
1012
1013        let client = reqwest_middleware::ClientBuilder::new(
1014            reqwest::Client::builder()
1015                .dns_resolver(middleware.dns_resolver())
1016                .build()
1017                .unwrap(),
1018        )
1019        .with(middleware)
1020        .build();
1021
1022        let response = client
1023            .get(format!("http://127.0.0.1:{}/", addr.port()))
1024            .send()
1025            .await
1026            .unwrap();
1027
1028        assert_eq!(response.status(), 201);
1029        assert!(!response.headers().contains_key("x-remove-me"));
1030        assert_eq!(response.headers().get("x-added").unwrap(), "yes");
1031        let body = response.text().await.unwrap();
1032        assert_eq!(body, "redacted body");
1033    }
1034
1035    #[tokio::test]
1036    async fn test_modify_response_preserves_url_and_remote_addr() {
1037        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1038        use tokio::net::TcpListener;
1039
1040        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1041        let addr = listener.local_addr().unwrap();
1042
1043        tokio::spawn(async move {
1044            if let Ok((mut socket, _)) = listener.accept().await {
1045                let mut buf = [0u8; 1024];
1046                let _ = socket.read(&mut buf).await;
1047                let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
1048                let _ = socket.write_all(response.as_bytes()).await;
1049            }
1050        });
1051
1052        let acl = HttpAcl::builder()
1053            .non_global_ip_ranges(true)
1054            .ip_acl_default(true)
1055            .port_acl_default(true)
1056            .host_acl_default(true)
1057            .build_full(HttpAclHooks {
1058                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1059                    mutation.body = Bytes::from_static(b"changed");
1060                })),
1061                ..Default::default()
1062            });
1063
1064        let middleware = HttpAclMiddleware::new(acl);
1065
1066        let client = reqwest_middleware::ClientBuilder::new(
1067            reqwest::Client::builder()
1068                .dns_resolver(middleware.dns_resolver())
1069                .build()
1070                .unwrap(),
1071        )
1072        .with(middleware)
1073        .build();
1074
1075        let url = format!("http://127.0.0.1:{}/", addr.port());
1076        let response = client.get(&url).send().await.unwrap();
1077
1078        assert_eq!(response.url().as_str(), url);
1079        assert!(
1080            response.remote_addr().is_some(),
1081            "remote_addr() was lost when the response was rebuilt"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn test_modify_request_header_carries_through_redirect() {
1087        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1088        use tokio::net::TcpListener;
1089
1090        let second_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1091        let second_addr = second_listener.local_addr().unwrap();
1092
1093        let (second_captured_tx, second_captured_rx) = tokio::sync::oneshot::channel();
1094        tokio::spawn(async move {
1095            if let Ok((mut socket, _)) = second_listener.accept().await {
1096                let mut buf = [0u8; 4096];
1097                let n = socket.read(&mut buf).await.unwrap_or(0);
1098                let _ = second_captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
1099                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
1100                let _ = socket.write_all(response.as_bytes()).await;
1101            }
1102        });
1103
1104        let first_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1105        let first_addr = first_listener.local_addr().unwrap();
1106        tokio::spawn(async move {
1107            if let Ok((mut socket, _)) = first_listener.accept().await {
1108                let mut buf = [0u8; 1024];
1109                let _ = socket.read(&mut buf).await;
1110                let response = format!(
1111                    "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: 0\r\n\r\n",
1112                    second_addr.port()
1113                );
1114                let _ = socket.write_all(response.as_bytes()).await;
1115            }
1116        });
1117
1118        let acl = HttpAcl::builder()
1119            .non_global_ip_ranges(true)
1120            .ip_acl_default(true)
1121            .port_acl_default(true)
1122            .host_acl_default(true)
1123            .build_full(HttpAclHooks {
1124                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1125                    mutation
1126                        .headers
1127                        .push(("x-marker".to_string(), "hop-one-only".to_string()));
1128                })),
1129                ..Default::default()
1130            });
1131
1132        let middleware = HttpAclMiddleware::new(acl);
1133
1134        let client = reqwest_middleware::ClientBuilder::new(
1135            reqwest::Client::builder()
1136                .dns_resolver(middleware.dns_resolver())
1137                .redirect(middleware.redirect_policy())
1138                .build()
1139                .unwrap(),
1140        )
1141        .with(middleware)
1142        .build();
1143
1144        let request = client
1145            .get(format!("http://127.0.0.1:{}/", first_addr.port()))
1146            .send()
1147            .await;
1148
1149        assert!(request.is_ok(), "{:?}", request.err());
1150        let second_captured = second_captured_rx.await.unwrap();
1151        // `ModifyRequestFn` only ever runs once, against the original request, but
1152        // `reqwest`'s own redirect handling carries a header set before `send()`
1153        // (which is indistinguishable from one injected by the closure) forward to
1154        // every hop - the header shows up here even though the closure itself
1155        // never ran again. See the `redirect_policy` doc comment.
1156        assert!(
1157            second_captured.to_lowercase().contains("x-marker"),
1158            "expected the header injected for the original request to carry through \
1159             reqwest's redirect handling to the second hop, but it did not:\n{second_captured}"
1160        );
1161    }
1162
1163    /// Accepts one connection, captures the raw request bytes, and answers with
1164    /// `response`.
1165    async fn capture_server(
1166        response: &'static [u8],
1167    ) -> (SocketAddr, tokio::sync::oneshot::Receiver<Vec<u8>>) {
1168        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1169        use tokio::net::TcpListener;
1170
1171        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1172        let addr = listener.local_addr().unwrap();
1173        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
1174        tokio::spawn(async move {
1175            if let Ok((mut socket, _)) = listener.accept().await {
1176                let mut buf = [0u8; 4096];
1177                let n = socket.read(&mut buf).await.unwrap_or(0);
1178                let _ = captured_tx.send(buf[..n].to_ascii_lowercase());
1179                let _ = socket.write_all(response).await;
1180            }
1181        });
1182        (addr, captured_rx)
1183    }
1184
1185    fn local_client(hooks: HttpAclHooks, builder: HttpAclBuilder) -> ClientWithMiddleware {
1186        let acl = builder
1187            .non_global_ip_ranges(true)
1188            .ip_acl_default(true)
1189            .port_acl_default(true)
1190            .host_acl_default(true)
1191            .build_full(hooks);
1192        let middleware = HttpAclMiddleware::new(acl);
1193        reqwest_middleware::ClientBuilder::new(
1194            reqwest::Client::builder()
1195                .dns_resolver(middleware.dns_resolver())
1196                .build()
1197                .unwrap(),
1198        )
1199        .with(middleware)
1200        .build()
1201    }
1202
1203    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
1204        haystack.windows(needle.len()).any(|w| w == needle)
1205    }
1206
1207    const OK_EMPTY: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
1208
1209    #[tokio::test]
1210    async fn test_obs_text_header_values_are_sent_unchanged() {
1211        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1212        let client = local_client(HttpAclHooks::default(), HttpAcl::builder());
1213
1214        let request = client
1215            .get(format!("http://127.0.0.1:{}/", addr.port()))
1216            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1217            .header("user-agent", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1218            .send()
1219            .await;
1220
1221        assert!(request.is_ok(), "{:?}", request.err());
1222        let captured = captured_rx.await.unwrap();
1223        assert!(
1224            contains(&captured, b"cookie: a=caf\xE9\r\n"),
1225            "{captured:?}"
1226        );
1227        assert!(
1228            contains(&captured, b"user-agent: caf\xE9\r\n"),
1229            "{captured:?}"
1230        );
1231    }
1232
1233    #[tokio::test]
1234    async fn test_denied_header_with_obs_text_value_is_still_denied() {
1235        let client = local_client(
1236            HttpAclHooks::default(),
1237            HttpAcl::builder()
1238                .add_denied_header("cookie".to_string(), None)
1239                .unwrap(),
1240        );
1241
1242        let request = client
1243            .get("http://127.0.0.1:9/")
1244            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1245            .send()
1246            .await;
1247
1248        let error = request.unwrap_err().to_string();
1249        assert!(error.starts_with("header cookie: "), "{error}");
1250        assert!(error.contains("is denied"), "{error}");
1251    }
1252
1253    #[tokio::test]
1254    async fn test_allowed_header_value_is_compared_as_raw_bytes() {
1255        // A lossy decode turns 0xE9 into U+FFFD, so comparing lossily would let
1256        // `caf\xE9` match an allow-listed `caf\u{FFFD}`.
1257        let builder = || {
1258            HttpAcl::builder()
1259                .add_allowed_header("x-token".to_string(), Some("caf\u{FFFD}".to_string()))
1260                .unwrap()
1261        };
1262
1263        let client = local_client(HttpAclHooks::default(), builder());
1264        let request = client
1265            .get("http://127.0.0.1:9/")
1266            .header("x-token", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1267            .send()
1268            .await;
1269        let error = request.unwrap_err().to_string();
1270        assert!(error.starts_with("header x-token: "), "{error}");
1271
1272        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1273        let client = local_client(HttpAclHooks::default(), builder());
1274        let request = client
1275            .get(format!("http://127.0.0.1:{}/", addr.port()))
1276            .header(
1277                "x-token",
1278                HeaderValue::from_bytes("caf\u{FFFD}".as_bytes()).unwrap(),
1279            )
1280            .send()
1281            .await;
1282        assert!(request.is_ok(), "{:?}", request.err());
1283        let captured = captured_rx.await.unwrap();
1284        assert!(
1285            contains(&captured, "x-token: caf\u{FFFD}\r\n".as_bytes()),
1286            "{captured:?}"
1287        );
1288    }
1289
1290    #[tokio::test]
1291    async fn test_validate_fn_sees_obs_text_headers() {
1292        // A `ValidateFn` must not have an obs-text header hidden from it, or a
1293        // custom denial keyed on that header could be bypassed.
1294        let client = local_client(
1295            HttpAclHooks {
1296                validate_fn: Some(Arc::new(|_scheme, _authority, mut headers, _body| {
1297                    if headers.any(|(k, v)| k == "cookie" && v == "a=caf\u{FFFD}") {
1298                        AclClassification::Denied("cookie seen".to_string())
1299                    } else {
1300                        AclClassification::AllowedDefault
1301                    }
1302                })),
1303                ..Default::default()
1304            },
1305            HttpAcl::builder(),
1306        );
1307
1308        let request = client
1309            .get("http://127.0.0.1:9/")
1310            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1311            .send()
1312            .await;
1313
1314        let error = request.unwrap_err().to_string();
1315        assert!(error.contains("cookie seen"), "{error}");
1316    }
1317
1318    #[tokio::test]
1319    async fn test_modify_request_keeps_obs_text_header_bytes() {
1320        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1321        let client = local_client(
1322            HttpAclHooks {
1323                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1324                    mutation.headers.retain(|(k, _)| k != "x-drop-me");
1325                    mutation
1326                        .headers
1327                        .push(("x-injected".to_string(), "yes".to_string()));
1328                })),
1329                ..Default::default()
1330            },
1331            HttpAcl::builder(),
1332        );
1333
1334        let request = client
1335            .get(format!("http://127.0.0.1:{}/", addr.port()))
1336            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1337            .header("x-drop-me", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1338            .send()
1339            .await;
1340
1341        assert!(request.is_ok(), "{:?}", request.err());
1342        let captured = captured_rx.await.unwrap();
1343        assert!(
1344            contains(&captured, b"cookie: a=caf\xE9\r\n"),
1345            "{captured:?}"
1346        );
1347        assert!(contains(&captured, b"x-injected: yes\r\n"), "{captured:?}");
1348        assert!(!contains(&captured, b"x-drop-me"), "{captured:?}");
1349    }
1350
1351    #[tokio::test]
1352    async fn test_modify_response_keeps_obs_text_header_bytes() {
1353        let (addr, _captured_rx) =
1354            capture_server(b"HTTP/1.1 200 OK\r\nx-raw: caf\xE9\r\nContent-Length: 2\r\n\r\nok")
1355                .await;
1356        let client = local_client(
1357            HttpAclHooks {
1358                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1359                    mutation.body = Bytes::from_static(b"changed");
1360                })),
1361                ..Default::default()
1362            },
1363            HttpAcl::builder(),
1364        );
1365
1366        let response = client
1367            .get(format!("http://127.0.0.1:{}/", addr.port()))
1368            .send()
1369            .await
1370            .unwrap();
1371
1372        assert_eq!(
1373            response.headers().get("x-raw").unwrap().as_bytes(),
1374            b"caf\xE9"
1375        );
1376        assert_eq!(response.text().await.unwrap(), "changed");
1377    }
1378}