Skip to main content

cratefield_runtime_native/ports/
http.rs

1//! `HttpClient` over `reqwest` (rustls, ring provider, webpki roots —
2//! no OpenSSL, no native-tls): the native counterpart of
3//! `worker::Fetch`. Adapters (Resend, Turnstile) run unchanged over
4//! either.
5//!
6//! This is the runtime that opens real sockets, so it is where the port's
7//! outbound bounds (issue #136) are enforced for real: the response cap is
8//! applied while streaming (an oversized body is refused before its last
9//! bytes are allocated), the deadline runs against the whole exchange,
10//! every destination is vetted — scheme, userinfo, IP literal or every
11//! address a name resolves to — and redirects are followed by the manual
12//! loop below, re-vetting each hop. `reqwest`'s own redirect policy is
13//! disabled because it would chase an attacker-chosen `Location` before
14//! any of this module saw it.
15//!
16//! # A failure message here never carries the request URL
17//!
18//! `reqwest::Error`'s `Display` appends ` for url (<the whole URL>)`, and
19//! for some callers that URL *is* the credential: an APNs request path is
20//! the device token (`/3/device/<token>`) and a Web Push endpoint is a
21//! bearer capability — whoever holds it can push to that browser, which is
22//! why `cratefield_core::Recipient` prints fingerprints and says the value
23//! must appear "never in a log, an event payload, or an error body". This
24//! module's errors are all three: they reach `tracing::error!`, an
25//! operator's terminal through `fz push send`, and whatever a module
26//! persists. So no `reqwest::Error` is ever stringified whole here — every
27//! one goes through [`safe_message`], which keeps the destination's origin
28//! and drops its path.
29
30use std::error::Error as _;
31use std::fmt::Write as _;
32use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
33use std::sync::Arc;
34
35use async_trait::async_trait;
36use bytes::Bytes;
37use cratefield_core::{
38    ByteStream, HttpClient, HttpError, HttpPolicy, MAX_CONCURRENT_REQUESTS,
39    declared_content_length, scrub_request_url,
40};
41use http::header::{
42    AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, LOCATION, PROXY_AUTHORIZATION,
43};
44use reqwest::Url;
45use tokio::sync::Semaphore;
46
47/// Redirect hops followed before the request is failed — the bound
48/// `reqwest`'s default policy carries.
49const MAX_REDIRECTS: usize = 10;
50
51/// Marker the [`GuardResolver`] puts in its errors so `send` can tell a
52/// policy refusal from transport noise (reqwest boxes resolver errors).
53const GUARD_TAG: &str = "blocked-by-outbound-policy";
54
55/// Name suffixes that are metadata/private-service aliases wherever they
56/// appear (`.internal` is Google's; `.local` is mDNS's).
57const BLOCKED_HOST_SUFFIXES: [&str; 3] = [".internal", ".internal.", ".local"];
58
59/// Exact hostnames that name a metadata endpoint on the major clouds.
60/// Refused by name before any resolver is consulted, because the danger
61/// is what they resolve *to* on a VM.
62const BLOCKED_HOSTS: [&str; 3] = ["metadata", "metadata.google", "metadata.google.internal"];
63
64/// Outbound hardening knobs for [`ReqwestClient`]. The defaults are the
65/// hardened ones: a deployment loosens them field by field, deliberately.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct OutboundOptions {
68    /// Allow destinations whose address is loopback. Off by default: the
69    /// native process must not be pointed at services on its own host.
70    /// A self-hosted deployment that *intends* to call a local sidecar
71    /// turns this on; private ranges and metadata stay blocked either
72    /// way.
73    pub allow_loopback: bool,
74    /// Concurrent outbound requests this process may have in flight —
75    /// the per-tenant budget (one native process serves one venture).
76    /// Past it, requests are refused, not queued.
77    pub max_concurrent: usize,
78}
79
80impl Default for OutboundOptions {
81    fn default() -> Self {
82        Self {
83            allow_loopback: false,
84            max_concurrent: MAX_CONCURRENT_REQUESTS,
85        }
86    }
87}
88
89/// A shared reqwest client. Built once per process (the connection pool
90/// is the `Client`); cheap to `Arc`, clone-free on the hot path.
91pub struct ReqwestClient {
92    client: reqwest::Client,
93    budget: Arc<Semaphore>,
94    allow_loopback: bool,
95}
96
97impl Default for ReqwestClient {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl ReqwestClient {
104    /// The hardened client: loopback, private and metadata destinations
105    /// refused, default concurrency budget.
106    ///
107    /// # Panics
108    ///
109    /// Only if the rustls backend cannot initialize, which a static
110    /// webpki-roots configuration cannot do at runtime — a startup-time
111    /// invariant, the same class as core's `HmacSigner::new` panic.
112    #[must_use]
113    pub fn new() -> Self {
114        Self::with_options(OutboundOptions::default())
115    }
116
117    /// The client with explicit hardening knobs — see [`OutboundOptions`].
118    /// Zero permits are clamped to one: a never-serving client is a
119    /// misconfiguration, not a mode.
120    ///
121    /// # Panics
122    ///
123    /// As [`ReqwestClient::new`].
124    #[must_use]
125    pub fn with_options(options: OutboundOptions) -> Self {
126        Self {
127            client: reqwest::Client::builder()
128                .redirect(reqwest::redirect::Policy::none())
129                // Re-vet every name the pool dials. The checks in `send`
130                // run first; this closes the check-then-connect window
131                // (a rebinding server answers the two lookups
132                // differently only if we let it).
133                .dns_resolver(Arc::new(GuardResolver {
134                    allow_loopback: options.allow_loopback,
135                }))
136                .build()
137                .expect("reqwest initializes: rustls with webpki roots is static"),
138            budget: Arc::new(Semaphore::new(options.max_concurrent.max(1))),
139            allow_loopback: options.allow_loopback,
140        }
141    }
142}
143
144#[async_trait]
145impl HttpClient for ReqwestClient {
146    async fn send(
147        &self,
148        request: http::Request<Bytes>,
149    ) -> Result<http::Response<Bytes>, HttpError> {
150        let policy = HttpPolicy::of_request(&request);
151        let permit = Arc::clone(&self.budget).try_acquire_owned().map_err(|_| {
152            HttpError::Transport("outbound concurrency budget exhausted".to_owned())
153        })?;
154        let start = tokio::time::Instant::now();
155
156        let (parts, mut body) = request.into_parts();
157        let mut method = parts.method;
158        let mut headers = parts.headers;
159        let mut url = Url::parse(&parts.uri.to_string())
160            .map_err(|err| HttpError::Transport(format!("invalid request uri: {err}")))?;
161        vet_static(&url, self.allow_loopback)?;
162        vet_resolved_host(&url, self.allow_loopback).await?;
163
164        let response = self
165            .exchange_until_final(
166                Some(ExchangeDeadline { policy, start }),
167                &mut method,
168                &mut headers,
169                &mut body,
170                &mut url,
171            )
172            .await?;
173
174        if let Some(declared) = declared_content_length(response.headers())
175            && declared > policy.max_response_bytes
176        {
177            return Err(HttpError::ResponseTooLarge {
178                limit: policy.max_response_bytes,
179            });
180        }
181        let status = response.status();
182        let headers_out = response.headers().clone();
183        let bytes = read_capped(response, policy.max_response_bytes).await?;
184        // The permit lives to here: the budget covers the whole exchange,
185        // not just the connect.
186        drop(permit);
187        let mut builder = http::Response::builder().status(status);
188        if let Some(target) = builder.headers_mut() {
189            *target = headers_out;
190        }
191        builder
192            .body(bytes)
193            .map_err(|err| HttpError::Transport(err.to_string()))
194    }
195
196    /// The streamed send has no bounds of its own — no head deadline, no
197    /// idle gap or whole-body ceiling on the body, no byte cap, no
198    /// declared-length refusal. All of those are
199    /// [`cratefield_core::BoundedHttpClient`]'s, which every runtime wires
200    /// around this transport; a bare `ReqwestClient` used directly streams
201    /// unbounded.
202    async fn send_streaming(
203        &self,
204        request: http::Request<Bytes>,
205    ) -> Result<http::Response<ByteStream>, HttpError> {
206        // The budget is held for the body's whole life, not just the head:
207        // a stream that runs for minutes is one in-flight request the whole
208        // time. The permit travels into the body stream and drops with it.
209        let permit = Arc::clone(&self.budget).try_acquire_owned().map_err(|_| {
210            HttpError::Transport("outbound concurrency budget exhausted".to_owned())
211        })?;
212        let (parts, mut body) = request.into_parts();
213        let mut method = parts.method;
214        let mut headers = parts.headers;
215        let mut url = Url::parse(&parts.uri.to_string())
216            .map_err(|err| HttpError::Transport(format!("invalid request uri: {err}")))?;
217        vet_static(&url, self.allow_loopback)?;
218        vet_resolved_host(&url, self.allow_loopback).await?;
219
220        // No deadline of its own: reqwest's per-request timeout is a
221        // *total* one and would kill a body whose whole point is to
222        // outlive the buffered deadline (see [`ExchangeDeadline`]). The
223        // bounds are `cratefield_core::BoundedHttpClient`'s — the head
224        // under the `HttpPolicy` deadline, the body under the
225        // `StreamPolicy` — measured through the Clock port, not reqwest.
226        let response = self
227            .exchange_until_final(None, &mut method, &mut headers, &mut body, &mut url)
228            .await?;
229
230        let status = response.status();
231        let headers_out = response.headers().clone();
232        let mut builder = http::Response::builder().status(status);
233        if let Some(target) = builder.headers_mut() {
234            *target = headers_out;
235        }
236        let body: ByteStream = Box::pin(futures_util::stream::unfold(
237            StreamingBody {
238                response: Some(response),
239                permit,
240            },
241            |mut state| async move {
242                // The response travels into the poll and back out with its
243                // chunk, so a wrapper that gives up on this poll drops the
244                // future — and the response with it, which is the abort.
245                let mut response = state.response.take()?;
246                match response.chunk().await {
247                    Ok(Some(chunk)) => {
248                        state.response = Some(response);
249                        Some((Ok(chunk), state))
250                    }
251                    // A clean end and a mid-body failure alike drop the
252                    // response here: the connection under it closes, the
253                    // budget goes back with the permit, and the stream (now
254                    // response-less) answers `None` to any later poll.
255                    Ok(None) => None,
256                    Err(err) => Some((Err(HttpError::Transport(safe_message(&err))), state)),
257                }
258            },
259        ));
260        builder
261            .body(body)
262            .map_err(|err| HttpError::Transport(err.to_string()))
263    }
264}
265
266/// The deadline an exchange answers under: the [`HttpPolicy`] timeout
267/// measured from `start`, re-armed against what is left at every redirect
268/// hop. `None` on the streaming path — reqwest's per-request timeout is a
269/// *total* one, running from the first connect to the last body byte, so
270/// it is the whole-exchange bound the buffered `send` promises and exactly
271/// what a streamed body cannot borrow: a body whose point is to outlive
272/// the 30 s buffered cap would be killed mid-stream. There the bounds come
273/// from [`cratefield_core::BoundedHttpClient`] instead — the head under
274/// the [`HttpPolicy`] deadline, the body under the
275/// [`cratefield_core::StreamPolicy`], both through the Clock port — and
276/// what the redirect loop still contributes on that path is the per-hop
277/// vetting, not a clock.
278struct ExchangeDeadline {
279    policy: HttpPolicy,
280    start: tokio::time::Instant,
281}
282
283impl ReqwestClient {
284    /// Sends, following vetted redirects until a non-3xx answer (or a
285    /// 3xx without a usable `Location`) comes back.
286    async fn exchange_until_final(
287        &self,
288        deadline: Option<ExchangeDeadline>,
289        method: &mut http::Method,
290        headers: &mut http::HeaderMap,
291        body: &mut Bytes,
292        url: &mut Url,
293    ) -> Result<reqwest::Response, HttpError> {
294        for hop in 0.. {
295            let mut builder = self.client.request(method.clone(), url.clone());
296            for (name, value) in headers.iter() {
297                builder = builder.header(name.clone(), value.clone());
298            }
299            if !body.is_empty() {
300                builder = builder.body(body.clone());
301            }
302            if let Some(deadline) = &deadline {
303                let remaining = deadline
304                    .policy
305                    .timeout
306                    .saturating_sub(deadline.start.elapsed());
307                if remaining.is_zero() {
308                    return Err(HttpError::DeadlineExceeded {
309                        after: deadline.policy.timeout,
310                    });
311                }
312                builder = builder.timeout(remaining);
313            }
314            let response = match builder.send().await {
315                Ok(response) => response,
316                Err(err) if err.is_timeout() => {
317                    // Only the bounded path arms a reqwest timeout, so only
318                    // there is a timeout-shaped failure the deadline itself.
319                    match &deadline {
320                        Some(deadline) => {
321                            return Err(HttpError::DeadlineExceeded {
322                                after: deadline.policy.timeout,
323                            });
324                        }
325                        None => return Err(send_failure(&err)),
326                    }
327                }
328                Err(err) => {
329                    // Never `err.to_string()`: it appends the request URL,
330                    // which for a push transport is the recipient's
331                    // credential. See `safe_message`.
332                    return Err(send_failure(&err));
333                }
334            };
335            let status = response.status();
336            if !is_redirect(status) {
337                return Ok(response);
338            }
339            let Some(next) = response
340                .headers()
341                .get(LOCATION)
342                .and_then(|value| value.to_str().ok())
343                .and_then(|value| url.join(value).ok())
344            else {
345                // A 3xx without a usable Location is the answer.
346                return Ok(response);
347            };
348            if hop == MAX_REDIRECTS {
349                return Err(HttpError::Transport(format!(
350                    "redirect limit ({MAX_REDIRECTS}) exceeded"
351                )));
352            }
353            vet_static(&next, self.allow_loopback)?;
354            vet_resolved_host(&next, self.allow_loopback).await?;
355            if next.authority() != url.authority() {
356                // Cross-origin hop: credentials were for the host we were
357                // told to call, never for one it redirects to.
358                headers.remove(AUTHORIZATION);
359                headers.remove(COOKIE);
360                headers.remove(PROXY_AUTHORIZATION);
361            }
362            if redirect_changes_method(status, method) {
363                *method = http::Method::GET;
364                *body = Bytes::new();
365                headers.remove(CONTENT_TYPE);
366                headers.remove(CONTENT_LENGTH);
367            }
368            *url = next;
369        }
370        unreachable!("bounded by the MAX_REDIRECTS return")
371    }
372}
373
374fn is_redirect(status: http::StatusCode) -> bool {
375    matches!(status.as_u16(), 301 | 302 | 303 | 307 | 308)
376}
377
378fn redirect_changes_method(status: http::StatusCode, method: &http::Method) -> bool {
379    match status.as_u16() {
380        303 => *method != http::Method::GET && *method != http::Method::HEAD,
381        301 | 302 => *method == http::Method::POST,
382        _ => false,
383    }
384}
385
386/// Reads the body chunk by chunk, refusing the moment the accumulated
387/// size would cross `limit` — an oversized response is rejected before
388/// its last bytes are ever allocated (issue #136).
389async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result<Bytes, HttpError> {
390    let mut buffer: Vec<u8> = Vec::new();
391    while let Some(chunk) = response
392        .chunk()
393        .await
394        .map_err(|err| HttpError::Transport(safe_message(&err)))?
395    {
396        if buffer.len() + chunk.len() > limit {
397            return Err(HttpError::ResponseTooLarge { limit });
398        }
399        buffer.extend_from_slice(&chunk);
400    }
401    Ok(Bytes::from(buffer))
402}
403
404/// The streamed body [`ReqwestClient::send_streaming`] returns: the
405/// `reqwest` response polled one chunk at a time, and the concurrency
406/// permit that paid for it. Both fields drop together — with the body —
407/// which is what closes the connection mid-exchange and releases the
408/// budget: the cancellation contract this port promises.
409struct StreamingBody {
410    /// `None` while a chunk poll is in flight (and forever after a
411    /// terminal error or a clean end), so a poll that never comes back
412    /// drops the response with it.
413    response: Option<reqwest::Response>,
414    /// The budget slot, held for as long as the body is being read — a
415    /// streamed exchange is one in-flight request its whole life.
416    #[allow(dead_code)] // dropped with the body; never read
417    permit: tokio::sync::OwnedSemaphorePermit,
418}
419
420/// A `reqwest::Error` as a message this crate may hand on — to
421/// `HttpError`, and from there to a log, a dead-letter row or an
422/// operator's terminal.
423///
424/// Two things happen here, and the second is why this is not
425/// `err.to_string()`:
426///
427/// 1. **The URL comes out.** reqwest's own `Display` is the kind plus
428///    ` for url (<the whole URL>)`, path and query included — see the
429///    module note above for why that is a disclosure and not a detail.
430/// 2. **The causes go in.** With the URL redacted, `Display` alone is
431///    just "error sending request": reqwest 0.12 keeps the interesting
432///    half — "tcp connect error: Connection refused", a TLS failure, the
433///    resolver's own refusal — in the `source()` chain and no longer
434///    writes it into `Display`. So the chain is walked, and scrubbed too,
435///    because a cause is free to quote what it was handed.
436fn safe_message(err: &reqwest::Error) -> String {
437    let mut message = err.to_string();
438    let mut cause = err.source();
439    while let Some(current) = cause {
440        let _ = write!(message, ": {current}");
441        cause = current.source();
442    }
443    scrub_url(message, err.url())
444}
445
446/// A failed `builder.send()` as the port's error: the resolver's refusal
447/// is recognized by its tag, everything else is a transport failure.
448fn send_failure(err: &reqwest::Error) -> HttpError {
449    let message = safe_message(err);
450    // The resolver's refusal arrives boxed *inside* the error, so the tag
451    // is in the cause chain rather than in `Display` — which is what
452    // `safe_message` walks, and what asking `Display` alone could not see.
453    if message.contains(GUARD_TAG) {
454        HttpError::BlockedDestination(message)
455    } else {
456        HttpError::Transport(message)
457    }
458}
459
460/// Cuts `url` back to its origin everywhere it appears in `message`: as
461/// the whole URL reqwest appends, and as the bare request target
462/// (`/3/device/<token>`) a cause is free to quote on its own.
463///
464/// The rule itself is [`cratefield_core::scrub_request_url`], shared with
465/// the Cloudflare port, which leaked the same value through a different
466/// error type (issue #229). One place decides what a URL may say in a
467/// message, or the two runtimes drift — and this is the runtime whose
468/// version of the answer was written first.
469fn scrub_url(message: String, url: Option<&Url>) -> String {
470    match url {
471        Some(url) => scrub_request_url(&message, url.as_str()),
472        None => message,
473    }
474}
475
476/// Every check that needs no network: scheme, userinfo, and the host —
477/// as an IP literal it is vetted directly (the `url` crate has already
478/// normalized decimal/octal/hex encodings into a real address), as a
479/// name only against the metadata/loopback names.
480fn vet_static(url: &Url, allow_loopback: bool) -> Result<(), HttpError> {
481    if url.scheme() != "http" && url.scheme() != "https" {
482        return Err(HttpError::BlockedDestination(format!(
483            "scheme `{}` is not allowed",
484            url.scheme()
485        )));
486    }
487    if !url.username().is_empty() || url.password().is_some() {
488        return Err(HttpError::BlockedDestination(
489            "userinfo in a request uri is not allowed".to_owned(),
490        ));
491    }
492    let Some(raw) = url.host_str() else {
493        return Err(HttpError::BlockedDestination("url has no host".to_owned()));
494    };
495    if let Some(v6) = raw
496        .strip_prefix('[')
497        .and_then(|inner| inner.strip_suffix(']'))
498        .and_then(|inner| inner.parse::<Ipv6Addr>().ok())
499    {
500        return vet_ip_with(IpAddr::V6(v6), allow_loopback);
501    }
502    if let Ok(v4) = raw.parse::<Ipv4Addr>() {
503        return vet_ip_with(IpAddr::V4(v4), allow_loopback);
504    }
505    vet_domain(raw)
506}
507
508/// The checks a name must pass *before* a resolver is consulted.
509/// Everything else about a name is judged by its addresses, in
510/// [`vet_resolved_host`] and [`GuardResolver`].
511fn vet_domain(name: &str) -> Result<(), HttpError> {
512    let lower = name.to_ascii_lowercase();
513    let trimmed = lower.strip_suffix('.').unwrap_or(&lower);
514    let refused = |what: &str| Err(HttpError::BlockedDestination(format!("`{name}` {what}")));
515    if trimmed == "localhost" || trimmed.ends_with(".localhost") {
516        // Loopback under another name — refused by name here so the
517        // answer never depends on whether /etc/hosts or DNS resolves it.
518        return refused("is a loopback name");
519    }
520    if BLOCKED_HOSTS.contains(&trimmed)
521        || BLOCKED_HOST_SUFFIXES
522            .iter()
523            .any(|suffix| trimmed.ends_with(suffix))
524    {
525        return refused("names a private or metadata service");
526    }
527    Ok(())
528}
529
530/// Async half of host vetting: a domain host is resolved and *every*
531/// address it answers is vetted. Fail closed — a name nothing resolves
532/// to is refused, and a rebinding server's second lookup is covered by
533/// [`GuardResolver`] at connect time.
534async fn vet_resolved_host(url: &Url, allow_loopback: bool) -> Result<(), HttpError> {
535    let Some(raw) = url.host_str() else {
536        return Err(HttpError::BlockedDestination("url has no host".to_owned()));
537    };
538    if raw.parse::<Ipv4Addr>().is_ok()
539        || raw
540            .strip_prefix('[')
541            .and_then(|inner| inner.strip_suffix(']'))
542            .is_some_and(|inner| inner.parse::<Ipv6Addr>().is_ok())
543    {
544        return Ok(());
545    }
546    let addrs = resolve(raw).await.map_err(|err| {
547        HttpError::BlockedDestination(format!(
548            "`{raw}` cannot be resolved ({err}); destinations are vetted fail-closed"
549        ))
550    })?;
551    if addrs.is_empty() {
552        return Err(HttpError::BlockedDestination(format!(
553            "`{raw}` resolves to no address"
554        )));
555    }
556    for ip in addrs {
557        vet_ip_with(ip, allow_loopback)?;
558    }
559    Ok(())
560}
561
562async fn resolve(name: &str) -> Result<Vec<IpAddr>, std::io::Error> {
563    let name = name.to_owned();
564    tokio::task::spawn_blocking(move || {
565        use std::net::ToSocketAddrs;
566        (name.as_str(), 0u16)
567            .to_socket_addrs()
568            .map(|addrs| addrs.map(|addr| addr.ip()).collect::<Vec<_>>())
569    })
570    .await
571    .map_err(std::io::Error::other)?
572}
573
574/// The address rule (issue #136): refuse everything that is not
575/// publicly routable — loopback, the RFC 1918 ranges, CGNAT (`100.64/10`,
576/// behind which Oracle's metadata endpoint lives), link-local (which
577/// carries `169.254.169.254`), the IPv6 unique-local range (which carries
578/// AWS's `fd00:ec2::254`), multicast, documentation and benchmarking
579/// blocks — and unwrap the IPv4 that IPv6 embeds (mapped, 6to4) so it is
580/// checked too, while refusing Teredo outright (its embedded address is
581/// the attacker's to choose).
582#[must_use]
583pub fn vet_ip_ok(ip: IpAddr, allow_loopback: bool) -> bool {
584    vet_ip_with(ip, allow_loopback).is_ok()
585}
586
587fn vet_ip_with(ip: IpAddr, allow_loopback: bool) -> Result<(), HttpError> {
588    let blocked = Err(HttpError::BlockedDestination(format!(
589        "{ip} is not a publicly-routable destination"
590    )));
591    match ip {
592        IpAddr::V4(v4) => {
593            if v4.is_loopback() {
594                return if allow_loopback { Ok(()) } else { blocked };
595            }
596            let octets = v4.octets();
597            let cgnat = octets[0] == 100 && octets[1] & 0b1100_0000 == 0b0100_0000;
598            // `is_benchmarking`/`is_reserved` are unstable `ip`-feature
599            // methods; these are the same ranges read off the octets.
600            let benchmarking = octets[0] == 198 && octets[1] & 0b1111_1110 == 18;
601            let reserved = octets[0] >= 240;
602            if v4.is_private()
603                || v4.is_link_local()
604                || v4.is_unspecified()
605                || v4.is_multicast()
606                || v4.is_broadcast()
607                || v4.is_documentation()
608                || benchmarking
609                || reserved
610                || octets[0] == 0
611                // CGNAT 100.64.0.0/10: `is_private` does not cover it.
612                || cgnat
613            {
614                blocked
615            } else {
616                Ok(())
617            }
618        }
619        IpAddr::V6(v6) => {
620            if v6.is_loopback() {
621                return if allow_loopback { Ok(()) } else { blocked };
622            }
623            // The `ip` feature's classifiers are unstable; these are the
624            // same predicates read off the address bytes directly.
625            let link_local = v6.segments()[0] & 0xffc0 == 0xfe80;
626            let unique_local = v6.segments()[0] & 0xfe00 == 0xfc00;
627            let documentation = v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8;
628            if v6.is_unspecified()
629                || v6.is_multicast()
630                || link_local
631                || unique_local
632                || documentation
633            {
634                return blocked;
635            }
636            // Teredo (2001::/32) tunnels IPv4 through a relay and can
637            // arrive at loopback behind this client's own check; refuse
638            // the form outright rather than decoding the far end.
639            if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0 {
640                return blocked;
641            }
642            if let Some(mapped) = v6.to_ipv4_mapped() {
643                return vet_ip_with(IpAddr::V4(mapped), allow_loopback);
644            }
645            let octets = v6.octets();
646            let embedded_v4 = || Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
647            // IPv4-compatible (`::a.b.c.d`, RFC 4291 4.5.5.1): deprecated,
648            // but `to_ipv4_mapped` does not recognise the form and a stack
649            // that still routes it lands on the embedded address — so
650            // `[::127.0.0.1]` would otherwise walk past every check above.
651            // `::` and `::1` are already answered further up.
652            if v6.segments()[..6] == [0, 0, 0, 0, 0, 0] {
653                return vet_ip_with(IpAddr::V4(embedded_v4()), allow_loopback);
654            }
655            // NAT64 well-known prefix (`64:ff9b::/96`, RFC 6052): where a
656            // translator is on the path, this *is* the embedded v4
657            // destination.
658            if v6.segments()[0] == 0x0064 && v6.segments()[1] == 0xff9b {
659                return vet_ip_with(IpAddr::V4(embedded_v4()), allow_loopback);
660            }
661            // 6to4 (2002::/16) embeds the real destination in octets 3-6.
662            if v6.segments()[0] == 0x2002 {
663                let octets = v6.octets();
664                let embedded = Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5]);
665                return vet_ip_with(IpAddr::V4(embedded), allow_loopback);
666            }
667            Ok(())
668        }
669    }
670}
671
672/// The connection-time re-vet: the pool calls this for every hostname it
673/// actually dials, so the addresses a request reaches are the ones
674/// vetted here, whatever a resolver answers the second time.
675struct GuardResolver {
676    allow_loopback: bool,
677}
678
679impl reqwest::dns::Resolve for GuardResolver {
680    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
681        let allow_loopback = self.allow_loopback;
682        let name = name.as_str().to_owned();
683        Box::pin(async move {
684            let refusal = |why: String| -> Box<dyn std::error::Error + Send + Sync> {
685                Box::new(std::io::Error::other(format!("{GUARD_TAG}: {why}")))
686            };
687            let addrs = resolve(&name)
688                .await
689                .map_err(|err| refusal(format!("`{name}` cannot be resolved ({err})")))?;
690            if addrs.is_empty() {
691                return Err(refusal(format!("`{name}` resolves to no address")));
692            }
693            for ip in &addrs {
694                if let Err(err) = vet_ip_with(*ip, allow_loopback) {
695                    return Err(refusal(err.to_string()));
696                }
697            }
698            Ok(Box::new(
699                addrs
700                    .into_iter()
701                    .map(|ip| std::net::SocketAddr::new(ip, 0))
702                    .collect::<Vec<_>>()
703                    .into_iter(),
704            ) as reqwest::dns::Addrs)
705        })
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    fn url(s: &str) -> Url {
714        Url::parse(s).expect("test url")
715    }
716
717    fn refused(s: &str) -> bool {
718        match vet_static(&url(s), false) {
719            Ok(()) => false,
720            Err(HttpError::BlockedDestination(_)) => true,
721            Err(other) => panic!("expected a policy refusal for {s}, got {other}"),
722        }
723    }
724
725    #[test]
726    fn loopback_is_blocked_everywhere_it_can_be_written() {
727        // Decimal, hex, octal and short IPv4 forms all normalize to
728        // 127.0.0.1 before this module sees the host.
729        for s in [
730            "http://127.0.0.1/",
731            "http://127.1/",
732            "http://2130706433/",
733            "http://0x7f000001/",
734            "http://0177.0.0.1/",
735            "http://[::1]/",
736            "http://[::ffff:7f00:1]/",
737            "http://[0:0:0:0:0:ffff:127.0.0.1]/",
738        ] {
739            assert!(refused(s), "{s} must be refused");
740        }
741    }
742
743    #[test]
744    fn private_and_special_ranges_are_blocked() {
745        for s in [
746            "http://10.0.0.1/",
747            "http://192.168.70.70/",
748            "http://172.16.0.1/",
749            "http://0.0.0.0/",
750            "http://[fe80::1]/",
751            "http://[::]/",
752            "http://[ff02::1]/",
753            "http://[2001:db8::1]/",
754            "http://100.100.100.200/",
755            "http://255.255.255.255/",
756        ] {
757            assert!(refused(s), "{s} must be refused");
758        }
759    }
760
761    #[test]
762    fn cloud_metadata_endpoints_are_blocked() {
763        // EC2 (v4 and v6), GCE by name, and the private-DNS name forms.
764        for s in [
765            "http://169.254.169.254/latest/meta-data/",
766            "http://[fd00:ec2::254]/",
767            "http://metadata.google.internal/",
768            "http://metadata/",
769            "http://foo.internal/",
770            "http://printer.local/",
771        ] {
772            assert!(refused(s), "{s} must be refused");
773        }
774    }
775
776    #[test]
777    fn non_http_schemes_and_userinfo_are_blocked() {
778        for s in [
779            "file:///etc/passwd",
780            "gopher://127.0.0.1:6379/",
781            "ftp://example.com/",
782            "http://admin:pw@example.com/",
783            "https://tok@169.254.169.254/",
784        ] {
785            assert!(refused(s), "{s} must be refused");
786        }
787    }
788
789    #[test]
790    fn ipv6_tunnel_forms_cannot_smuggle_an_ipv4_destination() {
791        // 6to4 carrying 127.0.0.1, and Teredo (refused outright).
792        assert!(refused("http://[2002:7f00:1::1]/"));
793        assert!(refused("http://[2001:0:4136:8000:0:63bf:3fff:fdd2]/"));
794        // IPv4-compatible: deprecated, unseen by `to_ipv4_mapped`, and
795        // routable enough to be worth refusing.
796        assert!(refused("http://[::127.0.0.1]/"));
797        assert!(refused("http://[::7f00:1]/"));
798        assert!(refused("http://[::10.0.0.1]/"));
799        // NAT64 well-known prefix carrying the EC2 metadata address.
800        assert!(refused("http://[64:ff9b::169.254.169.254]/"));
801        // The same forms carrying a public address stay allowed: the
802        // embedded destination is what is judged, not the shape.
803        assert!(!refused("http://[::93.184.216.34]/"));
804        assert!(!refused("http://[64:ff9b::93.184.216.34]/"));
805    }
806
807    #[test]
808    fn legitimate_public_destinations_still_pass() {
809        for s in [
810            "https://api.resend.com/emails",
811            "https://93.184.216.34/",
812            // Boundaries just outside the blocked ranges.
813            "http://172.15.255.255/",
814            "http://172.32.0.1/",
815            "http://100.63.255.255/",
816            "http://100.128.0.1/",
817        ] {
818            assert!(!refused(s), "{s} must be allowed");
819        }
820    }
821
822    #[test]
823    fn loopback_opt_in_admits_only_loopback() {
824        assert!(!vet_ip_ok(IpAddr::V4(Ipv4Addr::LOCALHOST), false));
825        assert!(vet_ip_ok(IpAddr::V4(Ipv4Addr::LOCALHOST), true));
826        assert!(
827            !vet_ip_ok(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)), true),
828            "metadata must stay blocked with loopback on"
829        );
830        assert!(
831            !vet_ip_ok(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)), true),
832            "private must stay blocked with loopback on"
833        );
834    }
835
836    #[test]
837    fn names_are_refused_before_any_lookup() {
838        // vet_static is synchronous: these pass or fail without a resolver.
839        assert!(refused("http://localhost:8080/x"));
840        assert!(refused("http://LOCALHOST./x"));
841        assert!(refused("http://sub.localhost/x"));
842        assert!(!refused("http://api.example.com/x"));
843    }
844
845    /// The disclosure this module's `safe_message` exists to stop: a
846    /// transport failure that quotes the request URL hands out the
847    /// recipient's credential. APNs addresses a device by *path*
848    /// (`/3/device/<token>`), so a client that reports
849    /// `err.to_string()` publishes the token to every log, dead-letter
850    /// row and terminal the error reaches — while the caller two lines
851    /// away is carefully printing a fingerprint.
852    ///
853    /// A real failure, through the real client: a port nothing is
854    /// listening on, so the connect is refused without a network.
855    #[tokio::test]
856    async fn a_transport_failure_names_the_origin_and_never_the_request_path() {
857        const TOKEN: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f900112233445566778899aabbccddeeff";
858
859        // Bound and dropped: the port is guaranteed free, so connecting
860        // to it is refused rather than answered or hung.
861        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port");
862        let addr = listener.local_addr().expect("its address");
863        drop(listener);
864
865        // Loopback is normally refused outright; this test is about the
866        // failure *message*, so the destination has to be reachable
867        // enough to fail on the connect.
868        let client = ReqwestClient::with_options(OutboundOptions {
869            allow_loopback: true,
870            ..OutboundOptions::default()
871        });
872        let request = http::Request::builder()
873            .method(http::Method::POST)
874            .uri(format!("http://{addr}/3/device/{TOKEN}"))
875            .body(Bytes::new())
876            .expect("test request");
877
878        let err = client
879            .send(request)
880            .await
881            .expect_err("nothing is listening");
882        let HttpError::Transport(message) = &err else {
883            panic!("a refused connect is a transport failure, got {err}");
884        };
885        assert!(
886            !message.contains(TOKEN),
887            "the device token reached the error message: {message}"
888        );
889        assert!(
890            !message.contains("/3/device"),
891            "the request path reached the error message: {message}"
892        );
893        // And it is still an error somebody can act on: which host, and
894        // what the operating system actually said.
895        assert!(
896            message.contains(&addr.to_string()),
897            "the destination is named: {message}"
898        );
899        assert!(
900            message.to_ascii_lowercase().contains("refused")
901                || message.to_ascii_lowercase().contains("connect"),
902            "the cause survives the redaction: {message}"
903        );
904    }
905
906    #[test]
907    fn method_change_follows_the_http_rules() {
908        let post = http::Method::POST;
909        let get = http::Method::GET;
910        let code = |n: u16| http::StatusCode::from_u16(n).expect("test status");
911        assert!(redirect_changes_method(code(303), &post));
912        assert!(redirect_changes_method(code(302), &post));
913        assert!(!redirect_changes_method(code(307), &post));
914        assert!(!redirect_changes_method(code(301), &get));
915        assert!(!redirect_changes_method(code(303), &get));
916    }
917}