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`. Constructed via
466/// [`HttpAclMiddleware::dns_resolver`] or [`HttpAclMiddleware::with_dns_resolver`],
467/// not directly.
468pub struct HttpAclDnsResolver {
469    dns_resolver: Arc<dyn Resolve>,
470    acl: Arc<HttpAcl>,
471}
472
473impl HttpAclDnsResolver {
474    /// Create a new ACL resolver that resolves hostnames via `getaddrinfo`.
475    pub fn new(middleware: &HttpAclMiddleware) -> Self {
476        Self {
477            dns_resolver: Arc::new(GaiResolver),
478            acl: middleware.acl(),
479        }
480    }
481
482    /// Create a new ACL resolver that delegates actual resolution to a custom
483    /// [`Resolve`] implementation.
484    pub fn with_dns_resolver(
485        middleware: &HttpAclMiddleware,
486        dns_resolver: Arc<dyn Resolve>,
487    ) -> Self {
488        Self {
489            dns_resolver,
490            acl: middleware.acl(),
491        }
492    }
493}
494
495impl Resolve for HttpAclDnsResolver {
496    fn resolve(&self, name: Name) -> Resolving {
497        if self.acl.is_host_allowed(name.as_str()).is_denied() {
498            let err: BoxError = Box::new(HttpAclError::HostDenied {
499                host: name.as_str().to_string(),
500            });
501            return Box::pin(future::ready(Err(err)));
502        }
503
504        let acl = self.acl.clone();
505        let resolver = self.dns_resolver.clone();
506
507        Box::pin(async move {
508            if let Some(tcp_address) = acl.resolve_trusted_static_dns_mapping(name.as_str()) {
509                // Trusted mappings intentionally bypass the IP/port ACL - the caller
510                // vouches for this destination (e.g. pinning a hostname to an internal
511                // address on purpose).
512                Ok(Box::new(std::iter::once(tcp_address))
513                    as Box<dyn Iterator<Item = SocketAddr> + Send>)
514            } else if let Some(tcp_address) = acl.resolve_static_dns_mapping(name.as_str()) {
515                // Regular static mappings must still pass the IP/port ACL, just like
516                // resolved addresses do below - otherwise they'd be a way to bypass it
517                // entirely (e.g. mapping a host to a private IP while non-global IPs
518                // are denied).
519                if acl.is_ip_allowed(&tcp_address.ip()).is_allowed()
520                    && acl.is_port_allowed(tcp_address.port()).is_allowed()
521                {
522                    Ok(Box::new(std::iter::once(tcp_address))
523                        as Box<dyn Iterator<Item = SocketAddr> + Send>)
524                } else {
525                    let err: BoxError =
526                        Box::new(std::io::Error::other("Static DNS mapping denied by ACL"));
527                    Err(err)
528                }
529            } else {
530                let resolved = resolver.resolve(name).await;
531                match resolved {
532                    Ok(addresses) => {
533                        let filtered = addresses
534                            .into_iter()
535                            .filter(|addr| {
536                                acl.is_ip_allowed(&addr.ip()).is_allowed()
537                                    && acl.is_port_allowed(addr.port()).is_allowed()
538                            })
539                            .collect::<Vec<_>>();
540                        Ok(Box::new(filtered.into_iter())
541                            as Box<dyn Iterator<Item = SocketAddr> + Send>)
542                    }
543                    Err(e) => Err(e),
544                }
545            }
546        })
547    }
548}
549
550#[derive(Error, Debug)]
551/// An error that can occur when resolving a host.
552///
553/// Returned by [`HttpAclDnsResolver`] when a hostname itself is denied by the ACL.
554/// Downcast the boxed error from a failed resolution to check for this specifically,
555/// as opposed to a lower-level resolution failure.
556pub enum HttpAclError {
557    /// Host resolution denied by ACL.
558    #[error("Host resolution denied by ACL: {host}")]
559    HostDenied {
560        /// The host that was denied.
561        host: String,
562    },
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use http_acl::acl::AclClassification;
569    use reqwest_middleware::ClientWithMiddleware;
570
571    #[tokio::test]
572    async fn test_http_acl_middleware() {
573        let acl = HttpAcl::builder()
574            .add_denied_host("example.com".to_string())
575            .unwrap()
576            .build();
577
578        let middleware = HttpAclMiddleware::new(acl);
579
580        let client = reqwest_middleware::ClientBuilder::new(
581            reqwest::Client::builder()
582                .dns_resolver(middleware.dns_resolver())
583                .build()
584                .unwrap(),
585        )
586        .with(middleware)
587        .build();
588
589        let request = client.get("http://example.com/").send().await;
590
591        assert!(request.is_err());
592        assert_eq!(
593            request.unwrap_err().to_string(),
594            "host example.com is denied - The entity is denied according to the denied ACL."
595        );
596    }
597
598    #[tokio::test]
599    async fn test_middleware_decodes_percent_encoded_path() {
600        let acl = HttpAcl::builder()
601            .add_allowed_host("example.com".to_string())
602            .unwrap()
603            .add_denied_url_path("/secret file".to_string())
604            .unwrap()
605            .build();
606
607        let middleware = HttpAclMiddleware::new(acl);
608
609        let client =
610            reqwest_middleware::ClientBuilder::new(reqwest::Client::builder().build().unwrap())
611                .with(middleware)
612                .build();
613
614        // Regression test: `Url::path()` is percent-encoded ("%20" for the space
615        // here), but `is_url_path_allowed` matches against the decoded path, so the
616        // middleware must decode before checking - otherwise this denied path would
617        // never match and the request would go through.
618        let request = client.get("http://example.com/secret%20file").send().await;
619
620        assert!(request.is_err());
621        assert!(
622            request
623                .unwrap_err()
624                .to_string()
625                .contains("path /secret file is denied")
626        );
627    }
628
629    #[tokio::test]
630    async fn test_dns_resolver_returns_typed_error_for_denied_host() {
631        let acl = HttpAcl::builder()
632            .add_denied_host("denied.example.com".to_string())
633            .unwrap()
634            .build();
635
636        let middleware = HttpAclMiddleware::new(acl);
637        let resolver = middleware.dns_resolver();
638
639        let name: reqwest::dns::Name = "denied.example.com".parse().unwrap();
640        let err = match resolver.resolve(name).await {
641            Ok(_) => panic!("expected resolution to be denied"),
642            Err(e) => e,
643        };
644
645        let acl_err = err
646            .downcast_ref::<HttpAclError>()
647            .expect("expected a HttpAclError");
648        assert!(matches!(
649            acl_err,
650            HttpAclError::HostDenied { host } if host == "denied.example.com"
651        ));
652    }
653
654    #[tokio::test]
655    async fn test_dns_resolver_resolves_hostnames() {
656        use tokio::io::{AsyncReadExt, AsyncWriteExt};
657        use tokio::net::TcpListener;
658
659        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
660        let addr = listener.local_addr().unwrap();
661
662        tokio::spawn(async move {
663            if let Ok((mut socket, _)) = listener.accept().await {
664                let mut buf = [0u8; 1024];
665                let _ = socket.read(&mut buf).await;
666                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
667                let _ = socket.write_all(response.as_bytes()).await;
668            }
669        });
670
671        let acl = HttpAcl::builder()
672            .non_global_ip_ranges(true)
673            .ip_acl_default(true)
674            .port_acl_default(true)
675            .host_acl_default(true)
676            .build();
677
678        let middleware = HttpAclMiddleware::new(acl);
679
680        let client = reqwest_middleware::ClientBuilder::new(
681            reqwest::Client::builder()
682                .dns_resolver(middleware.dns_resolver())
683                .build()
684                .unwrap(),
685        )
686        .with(middleware)
687        .build();
688
689        // Regression test: the default `GaiResolver` used to call `to_socket_addrs()` on a
690        // bare hostname (no port), which always errors, so *no* hostname could ever resolve.
691        let request = client
692            .get(format!("http://localhost:{}/", addr.port()))
693            .send()
694            .await;
695
696        assert!(request.is_ok(), "{:?}", request.err());
697    }
698
699    #[tokio::test]
700    async fn test_trusted_static_dns_mapping_bypasses_ip_port_acl() {
701        use tokio::io::{AsyncReadExt, AsyncWriteExt};
702        use tokio::net::TcpListener;
703
704        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
705        let addr = listener.local_addr().unwrap();
706
707        tokio::spawn(async move {
708            if let Ok((mut socket, _)) = listener.accept().await {
709                let mut buf = [0u8; 1024];
710                let _ = socket.read(&mut buf).await;
711                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
712                let _ = socket.write_all(response.as_bytes()).await;
713            }
714        });
715
716        // Deny everything at the IP/port level (the default), but pin "trusted.internal"
717        // to our mock server via a *trusted* static mapping, which should bypass that.
718        let acl = HttpAcl::builder()
719            .host_acl_default(true)
720            .add_trusted_static_dns_mapping("trusted.internal".to_string(), addr)
721            .unwrap()
722            .build();
723
724        assert!(acl.is_ip_allowed(&addr.ip()).is_denied());
725        assert!(acl.is_port_allowed(addr.port()).is_denied());
726
727        let middleware = HttpAclMiddleware::new(acl);
728
729        let client = reqwest_middleware::ClientBuilder::new(
730            reqwest::Client::builder()
731                .dns_resolver(middleware.dns_resolver())
732                .build()
733                .unwrap(),
734        )
735        .with(middleware)
736        .build();
737
738        // No explicit port in the URL - the connector must pick up the trusted
739        // mapping's port, proving both the IP and port ACL were bypassed for it.
740        let request = client.get("http://trusted.internal/").send().await;
741
742        assert!(request.is_ok(), "{:?}", request.err());
743    }
744
745    #[tokio::test]
746    async fn test_redirect_policy_blocks_disallowed_target() {
747        use tokio::io::{AsyncReadExt, AsyncWriteExt};
748        use tokio::net::TcpListener;
749
750        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
751        let addr = listener.local_addr().unwrap();
752
753        tokio::spawn(async move {
754            if let Ok((mut socket, _)) = listener.accept().await {
755                let mut buf = [0u8; 1024];
756                let _ = socket.read(&mut buf).await;
757                let response = "HTTP/1.1 302 Found\r\nLocation: http://192.168.1.1/\r\nContent-Length: 0\r\n\r\n";
758                let _ = socket.write_all(response.as_bytes()).await;
759            }
760        });
761
762        // Allow everything except one specific (non-global) IP, so the initial request to
763        // our local mock server succeeds but the redirect target is denied.
764        let acl = HttpAcl::builder()
765            .non_global_ip_ranges(true)
766            .ip_acl_default(true)
767            .port_acl_default(true)
768            .add_denied_ip_range((
769                "192.168.1.1".parse::<std::net::IpAddr>().unwrap(),
770                "192.168.1.1".parse::<std::net::IpAddr>().unwrap(),
771            ))
772            .unwrap()
773            .build();
774
775        let middleware = HttpAclMiddleware::new(acl);
776
777        let client = reqwest_middleware::ClientBuilder::new(
778            reqwest::Client::builder()
779                .dns_resolver(middleware.dns_resolver())
780                .redirect(middleware.redirect_policy())
781                .build()
782                .unwrap(),
783        )
784        .with(middleware)
785        .build();
786
787        let request = client
788            .get(format!("http://127.0.0.1:{}/", addr.port()))
789            .send()
790            .await;
791
792        assert!(request.is_err());
793    }
794
795    #[test]
796    fn test_no_hooks_configured_leaves_acl_unaffected() {
797        let acl = HttpAcl::builder().build();
798
799        assert!(!acl.has_modify_request());
800        assert!(!acl.has_modify_response());
801    }
802
803    #[tokio::test]
804    async fn test_modify_request_injects_header() {
805        use tokio::io::{AsyncReadExt, AsyncWriteExt};
806        use tokio::net::TcpListener;
807
808        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
809        let addr = listener.local_addr().unwrap();
810
811        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
812        tokio::spawn(async move {
813            if let Ok((mut socket, _)) = listener.accept().await {
814                let mut buf = [0u8; 4096];
815                let n = socket.read(&mut buf).await.unwrap_or(0);
816                let _ = captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
817                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
818                let _ = socket.write_all(response.as_bytes()).await;
819            }
820        });
821
822        let acl = HttpAcl::builder()
823            .non_global_ip_ranges(true)
824            .ip_acl_default(true)
825            .port_acl_default(true)
826            .host_acl_default(true)
827            .build_full(HttpAclHooks {
828                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
829                    mutation
830                        .headers
831                        .push(("x-injected-secret".to_string(), "sssh".to_string()));
832                })),
833                ..Default::default()
834            });
835
836        let middleware = HttpAclMiddleware::new(acl);
837
838        let client = reqwest_middleware::ClientBuilder::new(
839            reqwest::Client::builder()
840                .dns_resolver(middleware.dns_resolver())
841                .build()
842                .unwrap(),
843        )
844        .with(middleware)
845        .build();
846
847        let request = client
848            .get(format!("http://127.0.0.1:{}/", addr.port()))
849            .send()
850            .await;
851
852        assert!(request.is_ok(), "{:?}", request.err());
853        let captured = captured_rx.await.unwrap();
854        assert!(
855            captured.to_lowercase().contains("x-injected-secret: sssh"),
856            "captured request did not contain the injected header:\n{captured}"
857        );
858    }
859
860    #[tokio::test]
861    async fn test_modify_request_replaces_body_and_content_length() {
862        use tokio::io::{AsyncReadExt, AsyncWriteExt};
863        use tokio::net::TcpListener;
864
865        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
866        let addr = listener.local_addr().unwrap();
867
868        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
869        tokio::spawn(async move {
870            if let Ok((mut socket, _)) = listener.accept().await {
871                let mut buf = [0u8; 4096];
872                let n = socket.read(&mut buf).await.unwrap_or(0);
873                let _ = captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
874                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
875                let _ = socket.write_all(response.as_bytes()).await;
876            }
877        });
878
879        let new_body = "a much longer replacement body than the original";
880        let acl = HttpAcl::builder()
881            .non_global_ip_ranges(true)
882            .ip_acl_default(true)
883            .port_acl_default(true)
884            .host_acl_default(true)
885            .build_full(HttpAclHooks {
886                modify_request_fn: Some(Arc::new(move |_scheme, _authority, mutation| {
887                    mutation.body = Some(Bytes::from(new_body));
888                })),
889                ..Default::default()
890            });
891
892        let middleware = HttpAclMiddleware::new(acl);
893
894        let client = reqwest_middleware::ClientBuilder::new(
895            reqwest::Client::builder()
896                .dns_resolver(middleware.dns_resolver())
897                .build()
898                .unwrap(),
899        )
900        .with(middleware)
901        .build();
902
903        let request = client
904            .post(format!("http://127.0.0.1:{}/", addr.port()))
905            .body("short")
906            .send()
907            .await;
908
909        assert!(request.is_ok(), "{:?}", request.err());
910        let captured = captured_rx.await.unwrap();
911        assert!(
912            captured.contains(&format!("content-length: {}", new_body.len()))
913                || captured.contains(&format!("Content-Length: {}", new_body.len())),
914            "captured request did not have a Content-Length matching the replaced body:\n{captured}"
915        );
916        assert!(
917            captured.ends_with(new_body),
918            "captured request did not end with the replaced body:\n{captured}"
919        );
920    }
921
922    #[tokio::test]
923    async fn test_modify_response_rewrites_status_headers_and_body() {
924        use tokio::io::{AsyncReadExt, AsyncWriteExt};
925        use tokio::net::TcpListener;
926
927        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
928        let addr = listener.local_addr().unwrap();
929
930        tokio::spawn(async move {
931            if let Ok((mut socket, _)) = listener.accept().await {
932                let mut buf = [0u8; 1024];
933                let _ = socket.read(&mut buf).await;
934                let body = "original body";
935                let response = format!(
936                    "HTTP/1.1 200 OK\r\nx-remove-me: yes\r\nContent-Length: {}\r\n\r\n{}",
937                    body.len(),
938                    body
939                );
940                let _ = socket.write_all(response.as_bytes()).await;
941            }
942        });
943
944        let acl = HttpAcl::builder()
945            .non_global_ip_ranges(true)
946            .ip_acl_default(true)
947            .port_acl_default(true)
948            .host_acl_default(true)
949            .build_full(HttpAclHooks {
950                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
951                    mutation.status = 201;
952                    mutation.headers.retain(|(k, _)| k != "x-remove-me");
953                    mutation
954                        .headers
955                        .push(("x-added".to_string(), "yes".to_string()));
956                    mutation.body = Bytes::from_static(b"redacted body");
957                })),
958                ..Default::default()
959            });
960
961        let middleware = HttpAclMiddleware::new(acl);
962
963        let client = reqwest_middleware::ClientBuilder::new(
964            reqwest::Client::builder()
965                .dns_resolver(middleware.dns_resolver())
966                .build()
967                .unwrap(),
968        )
969        .with(middleware)
970        .build();
971
972        let response = client
973            .get(format!("http://127.0.0.1:{}/", addr.port()))
974            .send()
975            .await
976            .unwrap();
977
978        assert_eq!(response.status(), 201);
979        assert!(!response.headers().contains_key("x-remove-me"));
980        assert_eq!(response.headers().get("x-added").unwrap(), "yes");
981        let body = response.text().await.unwrap();
982        assert_eq!(body, "redacted body");
983    }
984
985    #[tokio::test]
986    async fn test_modify_response_preserves_url_and_remote_addr() {
987        use tokio::io::{AsyncReadExt, AsyncWriteExt};
988        use tokio::net::TcpListener;
989
990        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
991        let addr = listener.local_addr().unwrap();
992
993        tokio::spawn(async move {
994            if let Ok((mut socket, _)) = listener.accept().await {
995                let mut buf = [0u8; 1024];
996                let _ = socket.read(&mut buf).await;
997                let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok";
998                let _ = socket.write_all(response.as_bytes()).await;
999            }
1000        });
1001
1002        let acl = HttpAcl::builder()
1003            .non_global_ip_ranges(true)
1004            .ip_acl_default(true)
1005            .port_acl_default(true)
1006            .host_acl_default(true)
1007            .build_full(HttpAclHooks {
1008                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1009                    mutation.body = Bytes::from_static(b"changed");
1010                })),
1011                ..Default::default()
1012            });
1013
1014        let middleware = HttpAclMiddleware::new(acl);
1015
1016        let client = reqwest_middleware::ClientBuilder::new(
1017            reqwest::Client::builder()
1018                .dns_resolver(middleware.dns_resolver())
1019                .build()
1020                .unwrap(),
1021        )
1022        .with(middleware)
1023        .build();
1024
1025        let url = format!("http://127.0.0.1:{}/", addr.port());
1026        let response = client.get(&url).send().await.unwrap();
1027
1028        assert_eq!(response.url().as_str(), url);
1029        assert!(
1030            response.remote_addr().is_some(),
1031            "remote_addr() was lost when the response was rebuilt"
1032        );
1033    }
1034
1035    #[tokio::test]
1036    async fn test_modify_request_header_carries_through_redirect() {
1037        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1038        use tokio::net::TcpListener;
1039
1040        let second_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1041        let second_addr = second_listener.local_addr().unwrap();
1042
1043        let (second_captured_tx, second_captured_rx) = tokio::sync::oneshot::channel();
1044        tokio::spawn(async move {
1045            if let Ok((mut socket, _)) = second_listener.accept().await {
1046                let mut buf = [0u8; 4096];
1047                let n = socket.read(&mut buf).await.unwrap_or(0);
1048                let _ = second_captured_tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
1049                let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
1050                let _ = socket.write_all(response.as_bytes()).await;
1051            }
1052        });
1053
1054        let first_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1055        let first_addr = first_listener.local_addr().unwrap();
1056        tokio::spawn(async move {
1057            if let Ok((mut socket, _)) = first_listener.accept().await {
1058                let mut buf = [0u8; 1024];
1059                let _ = socket.read(&mut buf).await;
1060                let response = format!(
1061                    "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/\r\nContent-Length: 0\r\n\r\n",
1062                    second_addr.port()
1063                );
1064                let _ = socket.write_all(response.as_bytes()).await;
1065            }
1066        });
1067
1068        let acl = HttpAcl::builder()
1069            .non_global_ip_ranges(true)
1070            .ip_acl_default(true)
1071            .port_acl_default(true)
1072            .host_acl_default(true)
1073            .build_full(HttpAclHooks {
1074                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1075                    mutation
1076                        .headers
1077                        .push(("x-marker".to_string(), "hop-one-only".to_string()));
1078                })),
1079                ..Default::default()
1080            });
1081
1082        let middleware = HttpAclMiddleware::new(acl);
1083
1084        let client = reqwest_middleware::ClientBuilder::new(
1085            reqwest::Client::builder()
1086                .dns_resolver(middleware.dns_resolver())
1087                .redirect(middleware.redirect_policy())
1088                .build()
1089                .unwrap(),
1090        )
1091        .with(middleware)
1092        .build();
1093
1094        let request = client
1095            .get(format!("http://127.0.0.1:{}/", first_addr.port()))
1096            .send()
1097            .await;
1098
1099        assert!(request.is_ok(), "{:?}", request.err());
1100        let second_captured = second_captured_rx.await.unwrap();
1101        // `ModifyRequestFn` only ever runs once, against the original request, but
1102        // `reqwest`'s own redirect handling carries a header set before `send()`
1103        // (which is indistinguishable from one injected by the closure) forward to
1104        // every hop - the header shows up here even though the closure itself
1105        // never ran again. See the `redirect_policy` doc comment.
1106        assert!(
1107            second_captured.to_lowercase().contains("x-marker"),
1108            "expected the header injected for the original request to carry through \
1109             reqwest's redirect handling to the second hop, but it did not:\n{second_captured}"
1110        );
1111    }
1112
1113    /// Accepts one connection, captures the raw request bytes, and answers with
1114    /// `response`.
1115    async fn capture_server(
1116        response: &'static [u8],
1117    ) -> (SocketAddr, tokio::sync::oneshot::Receiver<Vec<u8>>) {
1118        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1119        use tokio::net::TcpListener;
1120
1121        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1122        let addr = listener.local_addr().unwrap();
1123        let (captured_tx, captured_rx) = tokio::sync::oneshot::channel();
1124        tokio::spawn(async move {
1125            if let Ok((mut socket, _)) = listener.accept().await {
1126                let mut buf = [0u8; 4096];
1127                let n = socket.read(&mut buf).await.unwrap_or(0);
1128                let _ = captured_tx.send(buf[..n].to_ascii_lowercase());
1129                let _ = socket.write_all(response).await;
1130            }
1131        });
1132        (addr, captured_rx)
1133    }
1134
1135    fn local_client(hooks: HttpAclHooks, builder: HttpAclBuilder) -> ClientWithMiddleware {
1136        let acl = builder
1137            .non_global_ip_ranges(true)
1138            .ip_acl_default(true)
1139            .port_acl_default(true)
1140            .host_acl_default(true)
1141            .build_full(hooks);
1142        let middleware = HttpAclMiddleware::new(acl);
1143        reqwest_middleware::ClientBuilder::new(
1144            reqwest::Client::builder()
1145                .dns_resolver(middleware.dns_resolver())
1146                .build()
1147                .unwrap(),
1148        )
1149        .with(middleware)
1150        .build()
1151    }
1152
1153    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
1154        haystack.windows(needle.len()).any(|w| w == needle)
1155    }
1156
1157    const OK_EMPTY: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
1158
1159    #[tokio::test]
1160    async fn test_obs_text_header_values_are_sent_unchanged() {
1161        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1162        let client = local_client(HttpAclHooks::default(), HttpAcl::builder());
1163
1164        let request = client
1165            .get(format!("http://127.0.0.1:{}/", addr.port()))
1166            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1167            .header("user-agent", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1168            .send()
1169            .await;
1170
1171        assert!(request.is_ok(), "{:?}", request.err());
1172        let captured = captured_rx.await.unwrap();
1173        assert!(
1174            contains(&captured, b"cookie: a=caf\xE9\r\n"),
1175            "{captured:?}"
1176        );
1177        assert!(
1178            contains(&captured, b"user-agent: caf\xE9\r\n"),
1179            "{captured:?}"
1180        );
1181    }
1182
1183    #[tokio::test]
1184    async fn test_denied_header_with_obs_text_value_is_still_denied() {
1185        let client = local_client(
1186            HttpAclHooks::default(),
1187            HttpAcl::builder()
1188                .add_denied_header("cookie".to_string(), None)
1189                .unwrap(),
1190        );
1191
1192        let request = client
1193            .get("http://127.0.0.1:9/")
1194            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1195            .send()
1196            .await;
1197
1198        let error = request.unwrap_err().to_string();
1199        assert!(error.starts_with("header cookie: "), "{error}");
1200        assert!(error.contains("is denied"), "{error}");
1201    }
1202
1203    #[tokio::test]
1204    async fn test_allowed_header_value_is_compared_as_raw_bytes() {
1205        // A lossy decode turns 0xE9 into U+FFFD, so comparing lossily would let
1206        // `caf\xE9` match an allow-listed `caf\u{FFFD}`.
1207        let builder = || {
1208            HttpAcl::builder()
1209                .add_allowed_header("x-token".to_string(), Some("caf\u{FFFD}".to_string()))
1210                .unwrap()
1211        };
1212
1213        let client = local_client(HttpAclHooks::default(), builder());
1214        let request = client
1215            .get("http://127.0.0.1:9/")
1216            .header("x-token", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1217            .send()
1218            .await;
1219        let error = request.unwrap_err().to_string();
1220        assert!(error.starts_with("header x-token: "), "{error}");
1221
1222        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1223        let client = local_client(HttpAclHooks::default(), builder());
1224        let request = client
1225            .get(format!("http://127.0.0.1:{}/", addr.port()))
1226            .header(
1227                "x-token",
1228                HeaderValue::from_bytes("caf\u{FFFD}".as_bytes()).unwrap(),
1229            )
1230            .send()
1231            .await;
1232        assert!(request.is_ok(), "{:?}", request.err());
1233        let captured = captured_rx.await.unwrap();
1234        assert!(
1235            contains(&captured, "x-token: caf\u{FFFD}\r\n".as_bytes()),
1236            "{captured:?}"
1237        );
1238    }
1239
1240    #[tokio::test]
1241    async fn test_validate_fn_sees_obs_text_headers() {
1242        // A `ValidateFn` must not have an obs-text header hidden from it, or a
1243        // custom denial keyed on that header could be bypassed.
1244        let client = local_client(
1245            HttpAclHooks {
1246                validate_fn: Some(Arc::new(|_scheme, _authority, mut headers, _body| {
1247                    if headers.any(|(k, v)| k == "cookie" && v == "a=caf\u{FFFD}") {
1248                        AclClassification::Denied("cookie seen".to_string())
1249                    } else {
1250                        AclClassification::AllowedDefault
1251                    }
1252                })),
1253                ..Default::default()
1254            },
1255            HttpAcl::builder(),
1256        );
1257
1258        let request = client
1259            .get("http://127.0.0.1:9/")
1260            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1261            .send()
1262            .await;
1263
1264        let error = request.unwrap_err().to_string();
1265        assert!(error.contains("cookie seen"), "{error}");
1266    }
1267
1268    #[tokio::test]
1269    async fn test_modify_request_keeps_obs_text_header_bytes() {
1270        let (addr, captured_rx) = capture_server(OK_EMPTY).await;
1271        let client = local_client(
1272            HttpAclHooks {
1273                modify_request_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1274                    mutation.headers.retain(|(k, _)| k != "x-drop-me");
1275                    mutation
1276                        .headers
1277                        .push(("x-injected".to_string(), "yes".to_string()));
1278                })),
1279                ..Default::default()
1280            },
1281            HttpAcl::builder(),
1282        );
1283
1284        let request = client
1285            .get(format!("http://127.0.0.1:{}/", addr.port()))
1286            .header("cookie", HeaderValue::from_bytes(b"a=caf\xE9").unwrap())
1287            .header("x-drop-me", HeaderValue::from_bytes(b"caf\xE9").unwrap())
1288            .send()
1289            .await;
1290
1291        assert!(request.is_ok(), "{:?}", request.err());
1292        let captured = captured_rx.await.unwrap();
1293        assert!(
1294            contains(&captured, b"cookie: a=caf\xE9\r\n"),
1295            "{captured:?}"
1296        );
1297        assert!(contains(&captured, b"x-injected: yes\r\n"), "{captured:?}");
1298        assert!(!contains(&captured, b"x-drop-me"), "{captured:?}");
1299    }
1300
1301    #[tokio::test]
1302    async fn test_modify_response_keeps_obs_text_header_bytes() {
1303        let (addr, _captured_rx) =
1304            capture_server(b"HTTP/1.1 200 OK\r\nx-raw: caf\xE9\r\nContent-Length: 2\r\n\r\nok")
1305                .await;
1306        let client = local_client(
1307            HttpAclHooks {
1308                modify_response_fn: Some(Arc::new(|_scheme, _authority, mutation| {
1309                    mutation.body = Bytes::from_static(b"changed");
1310                })),
1311                ..Default::default()
1312            },
1313            HttpAcl::builder(),
1314        );
1315
1316        let response = client
1317            .get(format!("http://127.0.0.1:{}/", addr.port()))
1318            .send()
1319            .await
1320            .unwrap();
1321
1322        assert_eq!(
1323            response.headers().get("x-raw").unwrap().as_bytes(),
1324            b"caf\xE9"
1325        );
1326        assert_eq!(response.text().await.unwrap(), "changed");
1327    }
1328}