Skip to main content

rightkit_http/
netpolicy.rs

1//! Outbound address policy: address classification and the SSRF guard behind every
2//! [`crate::Client`] and [`crate::AsyncClient`] request.
3//!
4//! The classification mirrors `rightkit-browser`'s `policy.rs` (`classify_ip`,
5//! `AddressClass`) with the same semantics, so a browser page and an HTTP call reach the
6//! same verdict for the same address. A test cross-checks the two tables.
7//!
8//! How the guard works:
9//!
10//! 1. The host of the request URL (and of every redirect hop) is resolved once, by this
11//!    crate's resolver. Each resolved address is classified. One denied address denies the
12//!    whole host.
13//! 2. Only the vetted addresses are handed to the connector, so the socket opens to an
14//!    address that was checked. There is no second lookup between check and connect.
15//! 3. Names that resolve to no address are denied.
16//!
17//! Default (public-web) policy denies loopback, RFC 1918, link-local (including
18//! `169.254.169.254`), CGNAT `100.64/10`, multicast, reserved, unspecified and broadcast
19//! addresses, IPv6 ULA `fc00::/7` and link-local `fe80::/10`, and IPv6 forms that embed an
20//! IPv4 address (mapped, compatible, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`) by the class of
21//! the embedded IPv4 address. Link-local and cloud-metadata destinations cannot be allowed
22//! by any hook.
23
24use std::error::Error;
25use std::fmt;
26use std::io;
27use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
28use std::str::FromStr;
29use std::sync::mpsc;
30use std::sync::Arc;
31use std::time::Duration;
32
33use crate::error::{HttpError, Phase};
34
35/// Address class of a destination. Everything except `Public` is denied by default.
36#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
37pub enum AddressClass {
38    Public,
39    Loopback,
40    /// `169.254/16`, `fe80::/10`; includes the cloud metadata endpoint.
41    LinkLocal,
42    /// RFC 1918, CGNAT `100.64/10`, ULA `fc00::/7`, site-local `fec0::/10`.
43    Private,
44    /// IETF protocol assignments, documentation, benchmarking, `240/4`, broadcast,
45    /// `2001:db8::/32`, Teredo.
46    Reserved,
47    Unspecified,
48    Multicast,
49}
50
51/// Classify one address. IPv6 forms that embed an IPv4 address take the embedded class.
52pub fn classify_ip(ip: IpAddr) -> AddressClass {
53    match ip {
54        IpAddr::V4(v4) => classify_v4(v4),
55        IpAddr::V6(v6) => classify_v6(v6),
56    }
57}
58
59fn classify_v4(ip: Ipv4Addr) -> AddressClass {
60    let [a, b, c, _] = ip.octets();
61    if a == 127 {
62        AddressClass::Loopback
63    } else if a == 0 {
64        AddressClass::Unspecified
65    } else if a == 169 && b == 254 {
66        AddressClass::LinkLocal
67    } else if a == 10
68        || (a == 172 && (16..=31).contains(&b))
69        || (a == 192 && b == 168)
70        || (a == 100 && (64..=127).contains(&b))
71    {
72        AddressClass::Private
73    } else if (224..=239).contains(&a) {
74        AddressClass::Multicast
75    } else if a >= 240
76        || (a == 192 && b == 0 && c == 0)
77        || (a == 192 && b == 0 && c == 2)
78        || (a == 198 && (b == 18 || b == 19))
79        || (a == 198 && b == 51 && c == 100)
80        || (a == 203 && b == 0 && c == 113)
81    {
82        AddressClass::Reserved
83    } else {
84        AddressClass::Public
85    }
86}
87
88fn v4_from_segments(hi: u16, lo: u16) -> Ipv4Addr {
89    Ipv4Addr::new((hi >> 8) as u8, hi as u8, (lo >> 8) as u8, lo as u8)
90}
91
92fn v4_from_octets(o: &[u8; 16], idx: [usize; 4]) -> Ipv4Addr {
93    Ipv4Addr::new(o[idx[0]], o[idx[1]], o[idx[2]], o[idx[3]])
94}
95
96/// Every IPv4 address an IPv6 address can embed: IPv4-mapped `::ffff:a.b.c.d`, the
97/// deprecated IPv4-compatible `::a.b.c.d`, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`, and the
98/// local-use NAT64 space `64:ff9b:1::/48` (RFC 8215). The local-use prefix may be deployed
99/// at several lengths (RFC 6052 section 2.2), so each of the /48, /56, /64 and /96 layouts
100/// is returned. `::`, `::1` and non-embedding addresses return nothing.
101pub fn embedded_v4s(ip: IpAddr) -> Vec<Ipv4Addr> {
102    let IpAddr::V6(v6) = ip else {
103        return Vec::new();
104    };
105    if v6.is_loopback() || v6.is_unspecified() {
106        return Vec::new();
107    }
108    let s = v6.segments();
109    if let Some(v4) = v6.to_ipv4_mapped() {
110        return vec![v4];
111    }
112    if s[..6] == [0, 0, 0, 0, 0, 0] || s[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
113        return vec![v4_from_segments(s[6], s[7])];
114    }
115    if s[0] == 0x2002 {
116        return vec![v4_from_segments(s[1], s[2])];
117    }
118    if s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 {
119        let o = v6.octets();
120        return vec![
121            v4_from_octets(&o, [12, 13, 14, 15]),
122            v4_from_octets(&o, [9, 10, 11, 12]),
123            v4_from_octets(&o, [7, 9, 10, 11]),
124            v4_from_octets(&o, [6, 7, 9, 10]),
125        ];
126    }
127    Vec::new()
128}
129
130/// The IPv4 address embedded in an IPv6 address (see [`embedded_v4s`]): the first
131/// non-public one when several layouts are possible, otherwise the first.
132pub fn embedded_v4(ip: IpAddr) -> Option<Ipv4Addr> {
133    let all = embedded_v4s(ip);
134    all.iter()
135        .copied()
136        .find(|v4| classify_v4(*v4) != AddressClass::Public)
137        .or_else(|| all.first().copied())
138}
139
140fn classify_v6(v6: Ipv6Addr) -> AddressClass {
141    let s = v6.segments();
142    if v6.is_loopback() {
143        return AddressClass::Loopback;
144    }
145    if v6.is_unspecified() {
146        return AddressClass::Unspecified;
147    }
148    let embedded = embedded_v4s(IpAddr::V6(v6));
149    if !embedded.is_empty() {
150        return embedded
151            .into_iter()
152            .map(classify_v4)
153            .find(|c| *c != AddressClass::Public)
154            .unwrap_or(AddressClass::Public);
155    }
156    if v6.is_multicast() {
157        AddressClass::Multicast
158    } else if s[0] & 0xffc0 == 0xfe80 {
159        AddressClass::LinkLocal
160    } else if s[0] & 0xfe00 == 0xfc00 || s[0] & 0xffc0 == 0xfec0 {
161        AddressClass::Private
162    } else if (s[0] == 0x2001 && s[1] == 0x0db8) || (s[0] == 0x2001 && s[1] == 0) {
163        AddressClass::Reserved
164    } else {
165        AddressClass::Public
166    }
167}
168
169/// Addresses no hook may allow: link-local (which holds `169.254.169.254`), AWS's IPv6
170/// infrastructure block `fd00:ec2::/32` (metadata `fd00:ec2::254`, DNS `fd00:ec2::253`),
171/// and the metadata addresses outside link-local: Alibaba `100.100.100.200`, Azure's
172/// wire server `168.63.129.16`, and Oracle/other `192.0.0.192`. IPv6 forms embedding one
173/// of these are covered too. Returns why the address is blocked.
174fn hard_block(ip: IpAddr) -> Option<&'static str> {
175    fn one(ip: IpAddr) -> Option<&'static str> {
176        if classify_ip(ip) == AddressClass::LinkLocal {
177            return Some("is link-local (cloud metadata range)");
178        }
179        match ip {
180            IpAddr::V4(v4)
181                if [[100, 100, 100, 200], [168, 63, 129, 16], [192, 0, 0, 192]]
182                    .contains(&v4.octets()) =>
183            {
184                Some("is a cloud metadata address")
185            }
186            IpAddr::V6(v6) if v6.segments()[0] == 0xfd00 && v6.segments()[1] == 0x0ec2 => {
187                Some("is in the AWS IPv6 metadata block fd00:ec2::/32")
188            }
189            _ => None,
190        }
191    }
192    one(ip).or_else(|| {
193        embedded_v4s(ip)
194            .into_iter()
195            .find_map(|v4| one(IpAddr::V4(v4)))
196    })
197}
198
199/// How a host string spelled an IP address.
200#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
201pub enum HostIpKind {
202    /// Four decimal parts without leading zeros: `1.1.1.1`.
203    Ipv4DottedQuad,
204    /// Any other IPv4 spelling `inet_aton` accepts: `127.1`, `0177.0.0.1`, `0x7f.0.0.1`,
205    /// `2130706433`, `10.1.2`, and the dotted quad with a trailing dot.
206    Ipv4Legacy,
207    /// An IPv6 literal, with or without brackets, including IPv4-mapped forms.
208    Ipv6,
209}
210
211/// A host string that is an IP address, as parsed by [`parse_host_ip`].
212#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
213pub struct ParsedHostIp {
214    pub addr: IpAddr,
215    pub kind: HostIpKind,
216    /// True for a dotted quad or an IPv6 literal, which every parser reads the same way.
217    /// False for [`HostIpKind::Ipv4Legacy`] spellings, which downstream parsers disagree on.
218    pub is_canonical: bool,
219}
220
221/// Parse a host string as an IP address the way `inet_aton` and system resolvers do, and say
222/// how it was spelled. The input is a host as it appears in a URL: ASCII case does not
223/// matter, IPv6 may be bracketed, and one trailing dot is allowed (and makes an IPv4
224/// spelling non-canonical).
225///
226/// Accepted, with the result:
227///
228/// | Input | `addr` | `kind` | `is_canonical` |
229/// |---|---|---|---|
230/// | `1.1.1.1` | 1.1.1.1 | `Ipv4DottedQuad` | true |
231/// | `127.1` | 127.0.0.1 | `Ipv4Legacy` | false |
232/// | `10.1.2` | 10.1.0.2 | `Ipv4Legacy` | false |
233/// | `0177.0.0.1` (octal parts) | 127.0.0.1 | `Ipv4Legacy` | false |
234/// | `0x7f.0.0.1`, `0x7f000001` (hex) | 127.0.0.1 | `Ipv4Legacy` | false |
235/// | `2130706433` (one 32-bit part) | 127.0.0.1 | `Ipv4Legacy` | false |
236/// | `1.1.1.1.` (trailing dot) | 1.1.1.1 | `Ipv4Legacy` | false |
237/// | `::1`, `[::1]`, `::ffff:127.0.0.1` | as written | `Ipv6` | true |
238///
239/// `inet_aton` rules: one to four parts; each part is decimal, octal (a leading `0`) or hex
240/// (`0x`); every part but the last is at most 255; the last part fills the remaining bytes
241/// (so `a.b.c` has a 16-bit last part and `a.b` a 24-bit one). Returns `None` for hostnames,
242/// for more than four parts, for out-of-range parts, for a digit outside the part's radix
243/// (`08.1`), and for empty parts.
244pub fn parse_host_ip(host: &str) -> Option<ParsedHostIp> {
245    let trimmed = host.trim_matches(['[', ']']);
246    if let Ok(ip) = trimmed.parse::<IpAddr>() {
247        let (kind, is_canonical) = match ip {
248            IpAddr::V4(_) => (HostIpKind::Ipv4DottedQuad, true),
249            IpAddr::V6(_) => (HostIpKind::Ipv6, true),
250        };
251        return Some(ParsedHostIp {
252            addr: ip,
253            kind,
254            is_canonical,
255        });
256    }
257    let (body, trailing_dot) = match trimmed.strip_suffix('.') {
258        Some(b) => (b, true),
259        None => (trimmed, false),
260    };
261    let v4 = parse_legacy_ipv4(body)?;
262    let canonical = !trailing_dot && body.eq_ignore_ascii_case(&v4.to_string());
263    Some(ParsedHostIp {
264        addr: IpAddr::V4(v4),
265        kind: if canonical {
266            HostIpKind::Ipv4DottedQuad
267        } else {
268            HostIpKind::Ipv4Legacy
269        },
270        is_canonical: canonical,
271    })
272}
273
274/// Parse an IPv4 address with the `inet_aton` rules described at [`parse_host_ip`]. This
275/// accepts every spelling, canonical or not, and does not accept a trailing dot, brackets
276/// or IPv6; use [`parse_host_ip`] when the spelling matters.
277pub fn parse_legacy_ipv4(host: &str) -> Option<Ipv4Addr> {
278    let parts: Vec<&str> = host.split('.').collect();
279    if parts.is_empty() || parts.len() > 4 {
280        return None;
281    }
282    let mut values = Vec::with_capacity(parts.len());
283    for part in &parts {
284        let (digits, radix) =
285            if let Some(hex) = part.strip_prefix("0x").or_else(|| part.strip_prefix("0X")) {
286                (hex, 16)
287            } else if part.len() > 1 && part.starts_with('0') {
288                (&part[1..], 8)
289            } else {
290                (*part, 10)
291            };
292        if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
293            return None;
294        }
295        values.push(u64::from_str_radix(digits, radix).ok()?);
296    }
297    let (last, init) = values.split_last()?;
298    if init.iter().any(|v| *v > 255) {
299        return None;
300    }
301    let tail_bytes = 4 - init.len();
302    if *last >= 1u64 << (8 * tail_bytes) {
303        return None;
304    }
305    let mut addr: u32 = 0;
306    for v in init {
307        addr = (addr << 8) | *v as u32;
308    }
309    addr = addr.checked_shl(8 * tail_bytes as u32).unwrap_or(0) | *last as u32;
310    Some(Ipv4Addr::from(addr))
311}
312
313/// Hostnames that always mean "cloud instance metadata".
314fn is_metadata_host(host: &str) -> bool {
315    matches!(
316        host,
317        "metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
318    ) || host.ends_with(".metadata.google.internal")
319}
320
321/// Lowercase, strip IPv6 brackets and one trailing dot.
322pub(crate) fn normalize_host(host: &str) -> String {
323    let h = host.trim_matches(['[', ']']).to_ascii_lowercase();
324    h.strip_suffix('.').map(str::to_string).unwrap_or(h)
325}
326
327/// A CIDR range such as `10.0.0.0/8` or `fd00::/8`.
328#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
329pub struct IpNet {
330    addr: IpAddr,
331    prefix: u8,
332}
333
334/// `IpNet` text or prefix length was invalid.
335#[derive(Clone, Debug, Eq, PartialEq)]
336pub struct ParseIpNetError(pub String);
337
338impl fmt::Display for ParseIpNetError {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        write!(f, "invalid CIDR range: {}", self.0)
341    }
342}
343impl Error for ParseIpNetError {}
344
345impl IpNet {
346    /// `prefix` is bits of the network part: at most 32 for IPv4, 128 for IPv6.
347    pub fn new(addr: IpAddr, prefix: u8) -> Result<Self, ParseIpNetError> {
348        let max = if addr.is_ipv4() { 32 } else { 128 };
349        if prefix > max {
350            return Err(ParseIpNetError(format!("prefix {prefix} exceeds {max}")));
351        }
352        Ok(Self { addr, prefix })
353    }
354
355    pub fn addr(&self) -> IpAddr {
356        self.addr
357    }
358
359    pub fn prefix(&self) -> u8 {
360        self.prefix
361    }
362
363    /// True when `ip` is in the range. Families must match; use [`embedded_v4`] first to
364    /// test an IPv6 form against an IPv4 range.
365    pub fn contains(&self, ip: &IpAddr) -> bool {
366        match (self.addr, ip) {
367            (IpAddr::V4(net), IpAddr::V4(ip)) => {
368                let mask = if self.prefix == 0 {
369                    0
370                } else {
371                    u32::MAX << (32 - u32::from(self.prefix))
372                };
373                u32::from(net) & mask == u32::from(*ip) & mask
374            }
375            (IpAddr::V6(net), IpAddr::V6(ip)) => {
376                let mask = if self.prefix == 0 {
377                    0
378                } else {
379                    u128::MAX << (128 - u32::from(self.prefix))
380                };
381                u128::from(net) & mask == u128::from(*ip) & mask
382            }
383            _ => false,
384        }
385    }
386}
387
388impl FromStr for IpNet {
389    type Err = ParseIpNetError;
390
391    /// `a.b.c.d/len`, `x::/len`, or a bare address (a single-host range).
392    fn from_str(s: &str) -> Result<Self, Self::Err> {
393        let (addr, prefix) = match s.split_once('/') {
394            Some((a, p)) => (a, Some(p)),
395            None => (s, None),
396        };
397        let addr: IpAddr = addr
398            .trim()
399            .parse()
400            .map_err(|_| ParseIpNetError(s.to_string()))?;
401        let prefix = match prefix {
402            Some(p) => p
403                .trim()
404                .parse::<u8>()
405                .map_err(|_| ParseIpNetError(s.to_string()))?,
406            None => {
407                if addr.is_ipv4() {
408                    32
409                } else {
410                    128
411                }
412            }
413        };
414        Self::new(addr, prefix)
415    }
416}
417
418impl fmt::Display for IpNet {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        write!(f, "{}/{}", self.addr, self.prefix)
421    }
422}
423
424/// Why an address or host was refused. Converts to [`crate::HttpError::AddressDenied`].
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub struct AddressDenial {
427    pub host: String,
428    /// The offending address; `None` when the denial is about the name or scheme.
429    pub addr: Option<IpAddr>,
430    pub class: Option<AddressClass>,
431    pub reason: String,
432}
433
434impl fmt::Display for AddressDenial {
435    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
436        write!(
437            f,
438            "address denied for host '{}': {}",
439            self.host, self.reason
440        )
441    }
442}
443impl Error for AddressDenial {}
444
445type DenyHook = Arc<dyn Fn(&IpAddr) -> Option<String> + Send + Sync>;
446type AllowHook = Arc<dyn Fn(&IpAddr) -> bool + Send + Sync>;
447type ResolverHook = Arc<dyn Fn(&str) -> Vec<IpAddr> + Send + Sync>;
448
449#[derive(Clone)]
450enum Mode {
451    PublicWeb,
452    FirstParty(Vec<String>),
453}
454
455#[derive(Clone)]
456struct AllowRule {
457    hosts: Vec<String>,
458    /// Applies to every host, not only the listed ones (see [`AddressPolicy::allow_if_any_host`]).
459    any_host: bool,
460    pred: AllowHook,
461}
462
463/// Outbound address policy carried by [`crate::ClientConfig::address_policy`].
464///
465/// The default is public-web policy: only public addresses are reachable. Callers add
466/// extra denials with [`AddressPolicy::deny_ranges`] and [`AddressPolicy::deny_if`], and
467/// open specific non-public destinations with [`AddressPolicy::allow_if`]. Custom denials
468/// always win over allowances.
469#[derive(Clone)]
470pub struct AddressPolicy {
471    mode: Mode,
472    deny_ranges: Vec<IpNet>,
473    deny_if: Vec<DenyHook>,
474    allow: Vec<AllowRule>,
475    /// First-party hosts allowed to resolve to non-public addresses.
476    private_ok: Vec<String>,
477    /// Follow an HTTPS to HTTP redirect (refused by default).
478    allow_downgrade: bool,
479    /// Accept non-canonical IPv4 host spellings (refused by default).
480    allow_legacy_ipv4: bool,
481    /// Refuse a host whose answer mixes loopback with non-loopback addresses.
482    refuse_mixed: bool,
483    resolver: Option<ResolverHook>,
484}
485
486/// Why [`AddressPolicy::resolve_vetted_async`] refused a host.
487#[derive(Debug)]
488pub enum ResolveError {
489    /// A private address (RFC 1918, CGNAT or unique-local) that the policy would admit once the
490    /// caller approves it, for example through [`AddressPolicy::allow_private_resolution`].
491    Private { addr: IpAddr },
492    /// Refused outright: link-local or metadata, a denied range, a custom denial, or a name rule.
493    Blocked {
494        addr: Option<IpAddr>,
495        class: Option<AddressClass>,
496        reason: String,
497    },
498    /// The name did not resolve, or the lookup failed.
499    Dns(HttpError),
500}
501
502impl fmt::Display for ResolveError {
503    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        match self {
505            ResolveError::Private { addr } => {
506                write!(f, "address {addr} is private and needs approval")
507            }
508            ResolveError::Blocked { reason, .. } => write!(f, "address refused: {reason}"),
509            ResolveError::Dns(e) => write!(f, "name lookup failed: {e}"),
510        }
511    }
512}
513
514impl Error for ResolveError {}
515
516impl Default for AddressPolicy {
517    fn default() -> Self {
518        Self::public_web()
519    }
520}
521
522impl fmt::Debug for AddressPolicy {
523    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524        let mut d = f.debug_struct("AddressPolicy");
525        match &self.mode {
526            Mode::PublicWeb => d.field("mode", &"public-web"),
527            Mode::FirstParty(hosts) => d.field("mode", &format_args!("first-party {hosts:?}")),
528        };
529        d.field("deny_ranges", &self.deny_ranges)
530            .field("deny_if", &self.deny_if.len())
531            .field(
532                "allow_if_hosts",
533                &self
534                    .allow
535                    .iter()
536                    .flat_map(|r| r.hosts.iter())
537                    .collect::<Vec<_>>(),
538            )
539            .field("private_resolution_hosts", &self.private_ok)
540            .field("allow_insecure_redirects", &self.allow_downgrade)
541            .field("allow_legacy_ipv4", &self.allow_legacy_ipv4)
542            .field("refuse_mixed_loopback", &self.refuse_mixed)
543            .field("custom_resolver", &self.resolver.is_some())
544            .finish()
545    }
546}
547
548impl AddressPolicy {
549    /// Public-web policy: only public addresses are reachable. This is the default.
550    pub fn public_web() -> Self {
551        Self {
552            mode: Mode::PublicWeb,
553            deny_ranges: Vec::new(),
554            deny_if: Vec::new(),
555            allow: Vec::new(),
556            private_ok: Vec::new(),
557            allow_downgrade: false,
558            allow_legacy_ipv4: false,
559            refuse_mixed: false,
560            resolver: None,
561        }
562    }
563
564    /// First-party policy: only the listed hosts (exact match, ASCII case-insensitive, one
565    /// trailing dot ignored), only over HTTPS, on any redirect hop as well. The resolved
566    /// addresses of those hosts are classified with the public-web classes too, so a listed
567    /// name that resolves to a private address is refused; opt a host out with
568    /// [`AddressPolicy::allow_private_resolution`]. Link-local and metadata destinations stay
569    /// denied and the custom denials below still apply.
570    pub fn first_party(hosts: Vec<String>) -> Self {
571        Self {
572            mode: Mode::FirstParty(hosts.iter().map(|h| normalize_host(h)).collect()),
573            ..Self::public_web()
574        }
575    }
576
577    /// True for a policy built with [`AddressPolicy::first_party`].
578    pub fn is_first_party(&self) -> bool {
579        matches!(self.mode, Mode::FirstParty(_))
580    }
581
582    /// Accept non-canonical IPv4 host spellings (`127.1`, `0177.0.0.1`, `0x7f.0.0.1`,
583    /// `2130706433`, `1.1.1.1.`) and read them as `inet_aton` does. They are refused by
584    /// default with [`crate::HttpError::AddressDenied`], because parsers disagree about
585    /// them (some read `0177.0.0.1` as decimal 177), and that mismatch is an SSRF bypass.
586    /// No legitimate URL spells a host this way. Cloud metadata and the address classes
587    /// still apply to an accepted spelling.
588    pub fn allow_legacy_ipv4(mut self) -> Self {
589        self.allow_legacy_ipv4 = true;
590        self
591    }
592
593    /// Follow redirects from HTTPS to plain HTTP. Refused by default: the redirect target
594    /// (often a signed URL) would travel unencrypted and the answer could not be trusted.
595    pub fn allow_insecure_redirects(mut self) -> Self {
596        self.allow_downgrade = true;
597        self
598    }
599
600    /// False for a redirect from HTTPS to plain HTTP, unless
601    /// [`AddressPolicy::allow_insecure_redirects`] was set. Other transports use it to apply
602    /// the same rule.
603    pub fn allows_redirect(&self, from_scheme: &str, to_scheme: &str) -> bool {
604        self.allow_downgrade
605            || !from_scheme.eq_ignore_ascii_case("https")
606            || !to_scheme.eq_ignore_ascii_case("http")
607    }
608
609    /// Refuse an HTTPS to HTTP redirect unless [`AddressPolicy::allow_insecure_redirects`].
610    pub(crate) fn check_redirect_scheme(
611        &self,
612        from_scheme: &str,
613        to_scheme: &str,
614        host: &str,
615    ) -> Result<(), AddressDenial> {
616        if self.allows_redirect(from_scheme, to_scheme) {
617            return Ok(());
618        }
619        Err(denial(
620            &normalize_host(host),
621            None,
622            None,
623            "redirect from HTTPS to HTTP refused",
624        ))
625    }
626
627    /// Let a first-party `host` resolve to non-public addresses (loopback, RFC 1918, ...),
628    /// for example an on-premises server. Exact host match like [`AddressPolicy::first_party`].
629    /// Link-local and metadata addresses, `deny_ranges` and `deny_if` still refuse. It has no
630    /// effect on hosts that are not first-party.
631    pub fn allow_private_resolution(mut self, host: impl Into<String>) -> Self {
632        self.private_ok.push(normalize_host(&host.into()));
633        self
634    }
635
636    /// Deny these ranges in addition to the class rules. An IPv6 address that embeds an
637    /// IPv4 address is also tested by its embedded IPv4 address.
638    pub fn deny_ranges(mut self, ranges: Vec<IpNet>) -> Self {
639        self.deny_ranges.extend(ranges);
640        self
641    }
642
643    /// Deny addresses for which `f` returns `Some(text)`. The text becomes the `reason` of
644    /// [`crate::HttpError::AddressDenied`]. `f` also sees the embedded IPv4 address of an
645    /// IPv6 form. Several hooks run in the order added.
646    pub fn deny_if<F>(mut self, f: F) -> Self
647    where
648        F: Fn(&IpAddr) -> Option<String> + Send + Sync + 'static,
649    {
650        self.deny_if.push(Arc::new(f));
651        self
652    }
653
654    /// Let the explicitly listed `hosts` (exact match, like [`AddressPolicy::first_party`])
655    /// reach addresses for which `pred` is true, even when their class is denied by default.
656    /// It does not apply to other hosts, never overrides link-local or metadata denials, and
657    /// never overrides `deny_ranges` or `deny_if`.
658    pub fn allow_if<F>(mut self, hosts: Vec<String>, pred: F) -> Self
659    where
660        F: Fn(&IpAddr) -> bool + Send + Sync + 'static,
661    {
662        self.allow.push(AllowRule {
663            hosts: hosts.iter().map(|h| normalize_host(h)).collect(),
664            any_host: false,
665            pred: Arc::new(pred),
666        });
667        self
668    }
669
670    /// Like [`AddressPolicy::allow_if`], but `pred` applies to every host: any name may reach
671    /// an address for which `pred` is true, even when its class is denied by default. Link-local
672    /// and metadata destinations, `deny_ranges` and `deny_if` still refuse. Use it only when the
673    /// predicate is the whole policy for the destination.
674    pub fn allow_if_any_host<F>(mut self, pred: F) -> Self
675    where
676        F: Fn(&IpAddr) -> bool + Send + Sync + 'static,
677    {
678        self.allow.push(AllowRule {
679            hosts: Vec::new(),
680            any_host: true,
681            pred: Arc::new(pred),
682        });
683        self
684    }
685
686    /// Allow loopback addresses (`127/8`, `::1`, and IPv4 forms embedded in IPv6) for every host,
687    /// for a local-first app whose loopback services are its own. Everything else stays public-only.
688    pub fn allow_loopback(self) -> Self {
689        self.allow_if_any_host(|ip| classify_ip(*ip) == AddressClass::Loopback)
690    }
691
692    /// Local-first policy: loopback plus RFC 1918 IPv4 (`10/8`, `172.16/12`, `192.168/16`) for
693    /// every host, public-only otherwise. Link-local and metadata destinations stay hard-blocked,
694    /// and CGNAT and IPv6 unique-local addresses are not admitted by this rule.
695    pub fn local_first(self) -> Self {
696        self.allow_if_any_host(|ip| {
697            if classify_ip(*ip) == AddressClass::Loopback {
698                return true;
699            }
700            let v4 = match ip {
701                IpAddr::V4(v) => Some(*v),
702                IpAddr::V6(_) => embedded_v4(*ip),
703            };
704            v4.is_some_and(|v| v.is_private())
705        })
706    }
707
708    /// Refuse a host whose DNS answer mixes loopback with non-loopback addresses. A host that
709    /// resolves to both is the shape of a DNS-rebinding attempt against a loopback service, and
710    /// `allow_loopback` or `local_first` would otherwise admit it.
711    pub fn refuse_mixed_loopback(mut self) -> Self {
712        self.refuse_mixed = true;
713        self
714    }
715
716    /// Replace the system resolver (offline tests, pinned DNS, split-horizon setups). The
717    /// hook gets the normalized host name and returns every address for it.
718    pub fn with_resolver<F>(mut self, f: F) -> Self
719    where
720        F: Fn(&str) -> Vec<IpAddr> + Send + Sync + 'static,
721    {
722        self.resolver = Some(Arc::new(f));
723        self
724    }
725
726    /// Resolve `host` once and vet every address with exactly the rules the HTTP clients
727    /// use: the address classes, `deny_ranges`, `deny_if`, `allow_if`, the first-party host
728    /// pin and its private-resolution opt-outs, metadata names, and the rule that a name with
729    /// no address is denied. Returns the vetted socket addresses on `port`. The error is the
730    /// same [`HttpError::AddressDenied`] the clients return; a failed lookup is a
731    /// [`HttpError::Transport`] or [`HttpError::Timeout`] in [`Phase::Connect`].
732    ///
733    /// Other transports (a WebSocket, a raw TCP or gRPC client) use it to apply the identical
734    /// policy, and **must connect to the returned addresses and not look the name up again**.
735    /// A transport that cannot pin the connection to these addresses leaves a DNS-rebinding
736    /// window between this check and its own lookup.
737    ///
738    /// The host is treated as reached over HTTPS: a first-party policy checks the host pin
739    /// but cannot see the caller's transport, so requiring TLS stays the caller's job. A
740    /// system lookup is bounded at ten seconds.
741    pub fn resolve_vetted(&self, host: &str, port: u16) -> Result<Vec<SocketAddr>, HttpError> {
742        self.admit("https", host)?;
743        let host = normalize_host(host);
744        let addrs = match self.fixed_addrs(&host) {
745            Some(addrs) => addrs,
746            None => match self.lookup_blocking(&host, port, Some(Duration::from_secs(10))) {
747                Ok(addrs) => addrs,
748                Err(e) if e.kind() == io::ErrorKind::TimedOut => {
749                    return Err(HttpError::timeout("name lookup", Phase::Connect))
750                }
751                Err(_) => return Err(HttpError::transport("host not found", Phase::Connect)),
752            },
753        };
754        let vetted = self.vet(&host, addrs)?;
755        Ok(vetted
756            .into_iter()
757            .map(|ip| SocketAddr::new(ip, port))
758            .collect())
759    }
760
761    /// True when some `allow_if` rule lists `host`.
762    fn host_listed(&self, host: &str) -> bool {
763        self.allow
764            .iter()
765            .any(|rule| rule.hosts.iter().any(|h| h == host))
766    }
767
768    /// Vet a destination that a proxy will resolve, so its address cannot be checked or
769    /// pinned here. IP literals (and `localhost`) are classified like any address. Names
770    /// are refused when they are `localhost`, `*.localhost`, `*.local`, `*.internal`, or
771    /// have a single label, unless an `allow_if` rule lists the host. First-party hosts are
772    /// pinned by [`AddressPolicy::admit`] instead and skip the name rules.
773    pub(crate) fn vet_via_proxy(&self, host: &str) -> Result<(), AddressDenial> {
774        let host = normalize_host(host);
775        if let Some(addrs) = self.fixed_addrs(&host) {
776            return self.vet(&host, addrs).map(|_| ());
777        }
778        if matches!(self.mode, Mode::FirstParty(_)) || self.host_listed(&host) {
779            return Ok(());
780        }
781        let reason = if host.ends_with(".local") {
782            Some("mDNS name is refused through a proxy")
783        } else if host.ends_with(".internal") {
784            Some("internal name is refused through a proxy")
785        } else if !host.contains('.') {
786            Some("single-label name is refused through a proxy")
787        } else {
788            None
789        };
790        match reason {
791            Some(r) => Err(denial(
792                &host,
793                None,
794                Some(AddressClass::Private),
795                format!("{r} (through a proxy only names and literals can be checked)"),
796            )),
797            None => Ok(()),
798        }
799    }
800
801    /// Name- and scheme-level admission, before any lookup: metadata host names, and the
802    /// first-party host and HTTPS pin.
803    ///
804    /// `host` should be the host exactly as the URL spelled it (trailing dot included), so a
805    /// non-canonical IPv4 spelling can be refused.
806    pub(crate) fn admit(&self, scheme: &str, host: &str) -> Result<(), AddressDenial> {
807        if !self.allow_legacy_ipv4 {
808            if let Some(parsed) = parse_host_ip(host) {
809                if !parsed.is_canonical {
810                    return Err(denial(
811                        &normalize_host(host),
812                        Some(parsed.addr),
813                        Some(classify_ip(parsed.addr)),
814                        format!(
815                            "non-canonical IPv4 spelling is refused (it reads as {})",
816                            parsed.addr
817                        ),
818                    ));
819                }
820            }
821        }
822        let host = normalize_host(host);
823        if is_metadata_host(&host) {
824            return Err(denial(
825                &host,
826                None,
827                Some(AddressClass::LinkLocal),
828                "host is a cloud metadata endpoint",
829            ));
830        }
831        if let Mode::FirstParty(hosts) = &self.mode {
832            if !scheme.eq_ignore_ascii_case("https") {
833                return Err(denial(
834                    &host,
835                    None,
836                    None,
837                    "first-party client allows HTTPS only",
838                ));
839            }
840            if !hosts.contains(&host) {
841                return Err(denial(
842                    &host,
843                    None,
844                    None,
845                    "host is not in the first-party host list",
846                ));
847            }
848        }
849        Ok(())
850    }
851
852    /// Addresses that need no lookup: IP literals and `localhost` names.
853    pub(crate) fn fixed_addrs(&self, host: &str) -> Option<Vec<IpAddr>> {
854        let host = normalize_host(host);
855        if let Some(parsed) = parse_host_ip(&host) {
856            return Some(vec![parsed.addr]);
857        }
858        if host == "localhost" || host.ends_with(".localhost") {
859            return Some(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
860        }
861        None
862    }
863
864    /// Resolve `host` with the configured hook or the system resolver. `limit` bounds the
865    /// lookup, hook included. `Ok(empty)` means the name resolved to nothing.
866    pub(crate) fn lookup_blocking(
867        &self,
868        host: &str,
869        port: u16,
870        limit: Option<Duration>,
871    ) -> io::Result<Vec<IpAddr>> {
872        let host = normalize_host(host);
873        let hook = self.resolver.clone();
874        let run = move || -> io::Result<Vec<IpAddr>> {
875            match hook {
876                Some(hook) => Ok(hook(&host)),
877                None => Ok((host, port).to_socket_addrs()?.map(|a| a.ip()).collect()),
878            }
879        };
880        match limit {
881            None => run(),
882            Some(limit) => {
883                let (tx, rx) = mpsc::sync_channel(1);
884                std::thread::spawn(move || tx.send(run()).ok());
885                match rx.recv_timeout(limit) {
886                    Ok(r) => r,
887                    Err(_) => Err(io::Error::new(
888                        io::ErrorKind::TimedOut,
889                        "name lookup timed out",
890                    )),
891                }
892            }
893        }
894    }
895
896    /// The configured resolver hook, for the async resolver.
897    #[cfg(feature = "async")]
898    pub(crate) fn resolver_hook(&self) -> Option<ResolverHook> {
899        self.resolver.clone()
900    }
901
902    /// Classify every resolved address and return them when all pass. One denied address
903    /// denies the host. Names with no address are denied.
904    pub(crate) fn vet(&self, host: &str, addrs: Vec<IpAddr>) -> Result<Vec<IpAddr>, AddressDenial> {
905        self.vet_with(host, addrs)
906    }
907
908    fn vet_with(&self, host: &str, addrs: Vec<IpAddr>) -> Result<Vec<IpAddr>, AddressDenial> {
909        let host = normalize_host(host);
910        if addrs.is_empty() {
911            return Err(denial(
912                &host,
913                None,
914                None,
915                "host did not resolve to any address",
916            ));
917        }
918        if self.refuse_mixed {
919            let loopback = addrs
920                .iter()
921                .any(|ip| classify_ip(*ip) == AddressClass::Loopback);
922            let other = addrs
923                .iter()
924                .any(|ip| classify_ip(*ip) != AddressClass::Loopback);
925            if loopback && other {
926                return Err(denial(
927                    &host,
928                    None,
929                    None,
930                    "host resolves to both loopback and non-loopback addresses",
931                ));
932            }
933        }
934        let literal = parse_host_ip(&host).is_some();
935        for &ip in &addrs {
936            let class = classify_ip(ip);
937            if let Some(why) = hard_block(ip) {
938                return Err(denial(
939                    &host,
940                    Some(ip),
941                    Some(class),
942                    format!("{} {why}", subject(literal, ip)),
943                ));
944            }
945            let forms: Vec<IpAddr> = std::iter::once(ip)
946                .chain(embedded_v4s(ip).into_iter().map(IpAddr::V4))
947                .collect();
948            for form in forms.iter() {
949                if let Some(net) = self.deny_ranges.iter().find(|n| n.contains(form)) {
950                    return Err(denial(
951                        &host,
952                        Some(ip),
953                        Some(class),
954                        format!("{} is in denied range {net}", subject(literal, ip)),
955                    ));
956                }
957                for hook in &self.deny_if {
958                    if let Some(text) = hook(form) {
959                        return Err(denial(&host, Some(ip), Some(class), text));
960                    }
961                }
962            }
963            let private_ok =
964                matches!(self.mode, Mode::FirstParty(_)) && self.private_ok.contains(&host);
965            if class != AddressClass::Public && !private_ok && !self.allowed(&host, &ip) {
966                let reason = if literal {
967                    format!("address {ip} is {class:?}")
968                } else {
969                    format!("host resolves to {class:?} address {ip}")
970                };
971                return Err(denial(&host, Some(ip), Some(class), reason));
972            }
973        }
974        Ok(addrs)
975    }
976
977    fn allowed(&self, host: &str, ip: &IpAddr) -> bool {
978        self.allow
979            .iter()
980            .any(|rule| (rule.any_host || rule.hosts.iter().any(|h| h == host)) && (rule.pred)(ip))
981    }
982
983    #[cfg(feature = "async")]
984    /// True when `ip` is an address an approval could admit: a private class that no
985    /// `deny_ranges` entry or `deny_if` hook refuses.
986    fn approvable(&self, ip: IpAddr) -> bool {
987        classify_ip(ip) == AddressClass::Private && !self.hard_denied(ip)
988    }
989
990    #[cfg(feature = "async")]
991    /// The hard-deny test [`AddressPolicy::vet`] applies: a metadata block, a `deny_ranges`
992    /// entry or a `deny_if` hook, checked on the address and on every IPv4 form embedded in it
993    /// (IPv4-mapped, NAT64 and similar), so no spelling of a denied address is approvable.
994    fn hard_denied(&self, ip: IpAddr) -> bool {
995        hard_block(ip).is_some()
996            || std::iter::once(ip)
997                .chain(embedded_v4s(ip).into_iter().map(IpAddr::V4))
998                .any(|form| {
999                    self.deny_ranges.iter().any(|n| n.contains(&form))
1000                        || self.deny_if.iter().any(|hook| hook(&form).is_some())
1001                })
1002    }
1003
1004    #[cfg(feature = "async")]
1005    /// Map a denial from [`AddressPolicy::vet`] onto the async resolver's error. A private
1006    /// address is `Private` only when every address of the answer is either admitted already or
1007    /// approvable; anything else is `Blocked`, so a hard block is never hidden behind an approval.
1008    fn to_resolve_error(
1009        &self,
1010        host: &str,
1011        denial: AddressDenial,
1012        addrs: &[IpAddr],
1013    ) -> ResolveError {
1014        let ok = |ip: &IpAddr| {
1015            !self.hard_denied(*ip)
1016                && (classify_ip(*ip) == AddressClass::Public
1017                    || self.allowed(host, ip)
1018                    || self.approvable(*ip))
1019        };
1020        match denial.addr {
1021            Some(addr)
1022                if classify_ip(addr) == AddressClass::Private
1023                    && self.approvable(addr)
1024                    && addrs.iter().all(ok) =>
1025            {
1026                ResolveError::Private { addr }
1027            }
1028            _ => ResolveError::Blocked {
1029                addr: denial.addr,
1030                class: denial.class,
1031                reason: denial.reason,
1032            },
1033        }
1034    }
1035
1036    /// Async form of [`AddressPolicy::resolve_vetted`] for an async caller (tokio, no
1037    /// `spawn_blocking`). It applies the same rules and returns the same vetted addresses, and
1038    /// it distinguishes the refusals an app can act on: a `Private` address may be approved,
1039    /// while `Blocked` (link-local, metadata, a denied range, a name rule) never is. A DNS
1040    /// failure or timeout is `Dns`. The lookup is bounded at ten seconds. The same
1041    /// connect-to-the-returned-addresses rule as `resolve_vetted` applies.
1042    #[cfg(feature = "async")]
1043    pub async fn resolve_vetted_async(
1044        &self,
1045        host: &str,
1046        port: u16,
1047    ) -> Result<Vec<SocketAddr>, ResolveError> {
1048        let host = normalize_host(host);
1049        self.admit("https", &host)
1050            .map_err(|d| self.to_resolve_error(&host, d, &[]))?;
1051        let addrs: Vec<IpAddr> = match self.fixed_addrs(&host) {
1052            Some(addrs) => addrs,
1053            None => match self.resolver_hook() {
1054                Some(hook) => hook(&host),
1055                None => match tokio::time::timeout(
1056                    Duration::from_secs(10),
1057                    tokio::net::lookup_host((host.as_str(), port)),
1058                )
1059                .await
1060                {
1061                    Ok(Ok(found)) => found.map(|a| a.ip()).collect(),
1062                    Ok(Err(_)) => {
1063                        return Err(ResolveError::Dns(HttpError::transport(
1064                            "host not found",
1065                            Phase::Connect,
1066                        )))
1067                    }
1068                    Err(_) => {
1069                        return Err(ResolveError::Dns(HttpError::timeout(
1070                            "name lookup",
1071                            Phase::Connect,
1072                        )))
1073                    }
1074                },
1075            },
1076        };
1077        if addrs.is_empty() {
1078            return Err(ResolveError::Dns(HttpError::transport(
1079                "host not found",
1080                Phase::Connect,
1081            )));
1082        }
1083        match self.vet(&host, addrs.clone()) {
1084            Ok(vetted) => Ok(vetted
1085                .into_iter()
1086                .map(|ip| SocketAddr::new(ip, port))
1087                .collect()),
1088            Err(d) => Err(self.to_resolve_error(&host, d, &addrs)),
1089        }
1090    }
1091}
1092
1093fn subject(literal: bool, ip: IpAddr) -> String {
1094    if literal {
1095        format!("address {ip}")
1096    } else {
1097        format!("resolved address {ip}")
1098    }
1099}
1100
1101fn denial(
1102    host: &str,
1103    addr: Option<IpAddr>,
1104    class: Option<AddressClass>,
1105    reason: impl Into<String>,
1106) -> AddressDenial {
1107    AddressDenial {
1108        host: host.to_string(),
1109        addr,
1110        class,
1111        reason: reason.into(),
1112    }
1113}
1114
1115/// Find an [`AddressDenial`] anywhere in an error's source chain.
1116pub(crate) fn denial_in_chain(err: &(dyn Error + 'static)) -> Option<AddressDenial> {
1117    let mut current: Option<&(dyn Error + 'static)> = Some(err);
1118    while let Some(e) = current {
1119        if let Some(d) = e.downcast_ref::<AddressDenial>() {
1120            return Some(d.clone());
1121        }
1122        current = e.source();
1123    }
1124    None
1125}
1126
1127thread_local! {
1128    /// True while the current thread is running a request that goes through the proxy.
1129    static VIA_PROXY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
1130}
1131
1132/// Marks the current thread's ureq call as routed through the proxy, so the resolver
1133/// treats the proxy endpoint (host and port) as trusted configuration. Without the mark
1134/// the same host and port is an ordinary destination and gets the full check, which is the
1135/// case for `NO_PROXY` hosts.
1136pub(crate) struct ViaProxy(bool);
1137
1138impl ViaProxy {
1139    pub(crate) fn enter(on: bool) -> Self {
1140        Self(VIA_PROXY.with(|c| c.replace(on)))
1141    }
1142}
1143
1144impl Drop for ViaProxy {
1145    fn drop(&mut self) {
1146        VIA_PROXY.with(|c| c.set(self.0));
1147    }
1148}
1149
1150/// `ureq` keeps at most this many addresses per lookup (its `ResolvedSocketAddrs` capacity).
1151const MAX_RESOLVED_ADDRS: usize = 16;
1152
1153/// Resolver for `ureq`: admits the URI, resolves it once, vets every address, and returns
1154/// only the vetted socket addresses. `ureq`'s `TcpConnector` connects to exactly the
1155/// addresses this returns (`ConnectionDetails::addrs`), so the check and the connection
1156/// use the same lookup.
1157#[derive(Debug)]
1158pub(crate) struct VettingResolver {
1159    pub(crate) policy: Arc<AddressPolicy>,
1160    /// Proxy endpoints `(normalized host, port)`: resolved but trusted (configuration, not
1161    /// input), so no address-class denial and no first-party host pin, but only while the
1162    /// call is marked [`ViaProxy`]. Usually one; a per-request system proxy configuration
1163    /// can name one per scheme.
1164    pub(crate) proxies: Vec<(String, u16)>,
1165    /// Bound for a name lookup when the request has no resolve or global timeout.
1166    pub(crate) lookup_limit: Duration,
1167}
1168
1169impl ureq::unversioned::resolver::Resolver for VettingResolver {
1170    fn resolve(
1171        &self,
1172        uri: &ureq::http::Uri,
1173        _config: &ureq::config::Config,
1174        timeout: ureq::unversioned::transport::NextTimeout,
1175    ) -> Result<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
1176        let scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
1177        let default_port = match scheme.as_str() {
1178            "http" => 80,
1179            "https" => 443,
1180            _ => return Err(ureq::Error::BadUri("unsupported scheme".into())),
1181        };
1182        let raw_host = uri
1183            .host()
1184            .ok_or_else(|| ureq::Error::BadUri("missing host".into()))?;
1185        let host = normalize_host(raw_host);
1186        let port = uri.port_u16().unwrap_or(default_port);
1187        let is_proxy = VIA_PROXY.with(|c| c.get())
1188            && self.proxies.iter().any(|(h, p)| *h == host && *p == port);
1189        if !is_proxy && !matches!(scheme.as_str(), "http" | "https") {
1190            return Err(ureq::Error::BadUri("unsupported scheme".into()));
1191        }
1192        if !is_proxy {
1193            self.policy.admit(&scheme, raw_host).map_err(to_ureq)?;
1194        }
1195        let addrs = match self.policy.fixed_addrs(&host) {
1196            Some(addrs) => addrs,
1197            None => {
1198                let limit = if timeout.after.is_not_happening() {
1199                    self.lookup_limit
1200                } else {
1201                    (*timeout.after).min(self.lookup_limit)
1202                };
1203                match self.policy.lookup_blocking(&host, port, Some(limit)) {
1204                    Ok(addrs) => addrs,
1205                    Err(e) if e.kind() == io::ErrorKind::TimedOut => {
1206                        return Err(ureq::Error::Timeout(timeout.reason))
1207                    }
1208                    Err(_) => return Err(ureq::Error::HostNotFound),
1209                }
1210            }
1211        };
1212        let vetted = if is_proxy {
1213            if addrs.is_empty() {
1214                return Err(ureq::Error::HostNotFound);
1215            }
1216            addrs
1217        } else {
1218            self.policy.vet(&host, addrs).map_err(to_ureq)?
1219        };
1220        let mut out = ureq::unversioned::resolver::Resolver::empty(self);
1221        for ip in vetted.into_iter().take(MAX_RESOLVED_ADDRS) {
1222            out.push(std::net::SocketAddr::new(ip, port));
1223        }
1224        Ok(out)
1225    }
1226}
1227
1228fn to_ureq(d: AddressDenial) -> ureq::Error {
1229    ureq::Error::Io(io::Error::new(io::ErrorKind::PermissionDenied, d))
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235
1236    fn ip(s: &str) -> IpAddr {
1237        s.parse().unwrap()
1238    }
1239
1240    #[test]
1241    fn ipnet_parses_and_matches() {
1242        let net: IpNet = "10.0.0.0/8".parse().unwrap();
1243        assert!(net.contains(&ip("10.255.0.1")));
1244        assert!(!net.contains(&ip("11.0.0.1")));
1245        assert!(!net.contains(&ip("::1")));
1246        let v6: IpNet = "fd00::/8".parse().unwrap();
1247        assert!(v6.contains(&ip("fd12::1")));
1248        assert!(!v6.contains(&ip("fe80::1")));
1249        let host: IpNet = "203.0.113.9".parse().unwrap();
1250        assert_eq!(host.prefix(), 32);
1251        assert!(host.contains(&ip("203.0.113.9")) && !host.contains(&ip("203.0.113.8")));
1252        assert!("10.0.0.0/33".parse::<IpNet>().is_err());
1253        assert!("nonsense".parse::<IpNet>().is_err());
1254        let all: IpNet = "0.0.0.0/0".parse().unwrap();
1255        assert!(all.contains(&ip("8.8.8.8")));
1256    }
1257
1258    #[test]
1259    fn embedded_forms_extract_the_v4() {
1260        assert_eq!(
1261            embedded_v4(ip("::ffff:10.1.2.3")),
1262            Some("10.1.2.3".parse().unwrap())
1263        );
1264        assert_eq!(
1265            embedded_v4(ip("::10.1.2.3")),
1266            Some("10.1.2.3".parse().unwrap())
1267        );
1268        assert_eq!(
1269            embedded_v4(ip("64:ff9b::a00:1")),
1270            Some("10.0.0.1".parse().unwrap())
1271        );
1272        assert_eq!(
1273            embedded_v4(ip("2002:0a00:0001::1")),
1274            Some("10.0.0.1".parse().unwrap())
1275        );
1276        assert_eq!(embedded_v4(ip("::1")), None);
1277        assert_eq!(embedded_v4(ip("::")), None);
1278        assert_eq!(embedded_v4(ip("2606:4700::1")), None);
1279        assert_eq!(embedded_v4(ip("1.2.3.4")), None);
1280    }
1281
1282    #[test]
1283    fn host_ip_parsing_reports_the_spelling() {
1284        let k = |h: &str| parse_host_ip(h).map(|p| (p.addr, p.kind, p.is_canonical));
1285        let v4 = |s: &str| IpAddr::V4(s.parse().unwrap());
1286        assert_eq!(
1287            k("1.1.1.1"),
1288            Some((v4("1.1.1.1"), HostIpKind::Ipv4DottedQuad, true))
1289        );
1290        for (spelling, want) in [
1291            ("127.1", "127.0.0.1"),
1292            ("10.1.2", "10.1.0.2"),
1293            ("0177.0.0.1", "127.0.0.1"),
1294            ("0x7f.0.0.1", "127.0.0.1"),
1295            ("0X7F000001", "127.0.0.1"),
1296            ("2130706433", "127.0.0.1"),
1297            ("017700000001", "127.0.0.1"),
1298            ("0251.0376.0251.0376", "169.254.169.254"),
1299            ("1.1.1.1.", "1.1.1.1"),
1300            ("0", "0.0.0.0"),
1301        ] {
1302            assert_eq!(
1303                k(spelling),
1304                Some((v4(want), HostIpKind::Ipv4Legacy, false)),
1305                "{spelling}"
1306            );
1307        }
1308        for v6 in [
1309            "::1",
1310            "[::1]",
1311            "::ffff:127.0.0.1",
1312            "[::ffff:7f00:1]",
1313            "64:ff9b::a00:1",
1314        ] {
1315            let p = parse_host_ip(v6).unwrap();
1316            assert!(p.is_canonical && p.kind == HostIpKind::Ipv6, "{v6}");
1317        }
1318        for not_ip in [
1319            "example.com",
1320            "1.2.3.4.5",
1321            "256.1.1.1",
1322            "1.2.3.256",
1323            "4294967296",
1324            "08.1",
1325            "0x",
1326            "1..2",
1327            "",
1328            ".",
1329            "1.2.3.",
1330            "a.b.c.d",
1331            "127.0.0.1x",
1332        ] {
1333            let got = parse_host_ip(not_ip);
1334            // "1.2.3." has a trailing dot on a valid 3-part spelling: that is a legacy IPv4.
1335            if not_ip == "1.2.3." {
1336                assert!(got.is_some());
1337            } else {
1338                assert!(got.is_none(), "{not_ip}: {got:?}");
1339            }
1340        }
1341        assert_eq!(
1342            parse_legacy_ipv4("0x7f.1"),
1343            Some("127.0.0.1".parse().unwrap())
1344        );
1345        assert_eq!(parse_legacy_ipv4("1.1.1.1."), None);
1346        assert_eq!(parse_legacy_ipv4("::1"), None);
1347    }
1348
1349    #[test]
1350    fn non_canonical_spellings_are_refused_by_default_and_allowed_on_request() {
1351        let strict = AddressPolicy::default();
1352        for h in ["0177.0.0.1", "0x7f.1", "2130706433", "127.1", "1.1.1.1."] {
1353            let d = strict.admit("http", h).unwrap_err();
1354            assert!(d.reason.contains("non-canonical"), "{h}: {d}");
1355            assert!(d.addr.is_some(), "{h}");
1356        }
1357        for h in [
1358            "1.1.1.1",
1359            "::ffff:1.1.1.1",
1360            "[::1]",
1361            "example.com",
1362            "example.com.",
1363        ] {
1364            assert!(strict.admit("http", h).is_ok(), "{h}");
1365        }
1366        let lax = AddressPolicy::default().allow_legacy_ipv4();
1367        assert!(lax.admit("http", "127.1").is_ok());
1368        // The address classes still apply to an accepted spelling.
1369        assert!(lax.vet("127.1", lax.fixed_addrs("127.1").unwrap()).is_err());
1370    }
1371
1372    #[test]
1373    fn host_normalization() {
1374        assert_eq!(normalize_host("[::1]"), "::1");
1375        assert_eq!(normalize_host("Example.COM."), "example.com");
1376    }
1377
1378    #[test]
1379    fn vet_denies_empty_resolution() {
1380        let d = AddressPolicy::default()
1381            .vet("nowhere.test", vec![])
1382            .unwrap_err();
1383        assert_eq!(d.addr, None);
1384        assert!(d.reason.contains("did not resolve"), "{d}");
1385    }
1386}
1387
1388#[cfg(test)]
1389mod loopback_tests {
1390    use super::*;
1391
1392    fn answers(addrs: Vec<IpAddr>) -> AddressPolicy {
1393        AddressPolicy::public_web().with_resolver(move |_| addrs.clone())
1394    }
1395
1396    #[test]
1397    fn loopback_is_admitted_only_when_allowed() {
1398        let lo: IpAddr = "127.0.0.1".parse().unwrap();
1399        assert!(answers(vec![lo]).resolve_vetted("svc.test", 80).is_err());
1400        assert!(answers(vec![lo])
1401            .allow_loopback()
1402            .resolve_vetted("svc.test", 80)
1403            .is_ok());
1404    }
1405
1406    #[test]
1407    fn local_first_admits_rfc1918_but_not_cgnat_link_local_or_metadata() {
1408        let rfc1918: IpAddr = "192.168.1.5".parse().unwrap();
1409        assert!(answers(vec![rfc1918])
1410            .local_first()
1411            .resolve_vetted("svc.test", 80)
1412            .is_ok());
1413        let meta: IpAddr = "169.254.169.254".parse().unwrap();
1414        assert!(answers(vec![meta])
1415            .local_first()
1416            .resolve_vetted("svc.test", 80)
1417            .is_err());
1418        let cgnat: IpAddr = "100.64.0.1".parse().unwrap();
1419        assert!(answers(vec![cgnat])
1420            .local_first()
1421            .resolve_vetted("svc.test", 80)
1422            .is_err());
1423    }
1424
1425    #[test]
1426    fn mixed_loopback_answers_are_refused_when_asked() {
1427        let mixed: Vec<IpAddr> = vec![
1428            "127.0.0.1".parse().unwrap(),
1429            "93.184.216.34".parse().unwrap(),
1430        ];
1431        assert!(answers(mixed.clone())
1432            .allow_loopback()
1433            .resolve_vetted("svc.test", 80)
1434            .is_ok());
1435        assert!(answers(mixed)
1436            .allow_loopback()
1437            .refuse_mixed_loopback()
1438            .resolve_vetted("svc.test", 80)
1439            .is_err());
1440    }
1441}
1442
1443/// Where credential headers may be sent: one canonical origin plus a whole-segment path
1444/// prefix, for example `https://api.example.com/api`. Configured per request with
1445/// [`crate::Request::credential_scope`]; the clients strip credential-named and
1446/// secret-valued headers on the first request and on every redirect hop whose URL is
1447/// outside the scope, exactly as they already do for a cross-origin hop. The scope never
1448/// admits a destination: the outbound address policy still runs on every hop, so a
1449/// metadata or link-local target inside the path prefix stays denied.
1450///
1451/// Matching is by decoded path segment, never by string prefix: scope `/api` admits
1452/// `/api` and `/api/users`, and refuses `/apievil`, a `..` or `.` segment (raw or
1453/// percent-encoded), a segment that decodes to text containing `/` or `\`, and any path
1454/// containing a backslash. Origin comparison is canonical (scheme and host lowercased,
1455/// one trailing dot ignored, default ports folded), the same form the redirect rules use.
1456#[derive(Clone, Debug, PartialEq, Eq)]
1457pub struct CredentialScope {
1458    scheme: String,
1459    host: String,
1460    port: u16,
1461    segments: Vec<String>,
1462}
1463
1464impl CredentialScope {
1465    /// Parse an absolute `http`/`https` URL naming the scope, such as
1466    /// `https://api.example.com:8443/api`. `None` for anything malformed, a URL with
1467    /// userinfo, a query or a fragment, or a path the segment rules refuse (above).
1468    pub fn parse(scope: &str) -> Option<Self> {
1469        if scope.contains(['?', '#']) {
1470            return None;
1471        }
1472        let (scheme, host, port, segments, userinfo) = credential_url_parts(scope)?;
1473        if userinfo {
1474            return None;
1475        }
1476        Some(Self {
1477            scheme,
1478            host,
1479            port,
1480            segments,
1481        })
1482    }
1483
1484    /// True when credentials scoped here may be sent to `url`: same canonical origin and
1485    /// a path whose decoded segments start with this scope's segments.
1486    pub fn allows(&self, url: &str) -> bool {
1487        let Some((scheme, host, port, segments, _)) = credential_url_parts(url) else {
1488            return false;
1489        };
1490        scheme == self.scheme
1491            && host == self.host
1492            && port == self.port
1493            && segments.len() >= self.segments.len()
1494            && segments[..self.segments.len()] == self.segments[..]
1495    }
1496}
1497
1498/// `(scheme, canonical host, port, decoded path segments, had userinfo)` of an absolute
1499/// http(s) URL, or `None` when the URL (or its path) is outside every credential scope:
1500/// another scheme, no host, whitespace/control/backslash anywhere, a bad port, or a path
1501/// segment that is `.`/`..` or decodes to text containing a separator.
1502fn credential_url_parts(url: &str) -> Option<(String, String, u16, Vec<String>, bool)> {
1503    if url
1504        .chars()
1505        .any(|c| c.is_control() || c.is_whitespace() || c == '\\')
1506    {
1507        return None;
1508    }
1509    let (scheme, rest) = url.split_once("://")?;
1510    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
1511        return None;
1512    }
1513    let scheme = scheme.to_ascii_lowercase();
1514    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1515    let (authority, tail) = rest.split_at(end);
1516    let (host_port, userinfo) = match authority.rsplit_once('@') {
1517        Some((_, host_port)) => (host_port, true),
1518        None => (authority, false),
1519    };
1520    let (raw_host, port) = if let Some(inner) = host_port.strip_prefix('[') {
1521        let (literal, after) = inner.split_once(']')?;
1522        let port = match after {
1523            "" => None,
1524            _ => Some(after.strip_prefix(':')?.parse().ok()?),
1525        };
1526        (literal, port)
1527    } else if let Some((host, port)) = host_port.rsplit_once(':') {
1528        if port.is_empty() {
1529            (host, None)
1530        } else {
1531            (host, Some(port.parse().ok()?))
1532        }
1533    } else {
1534        (host_port, None)
1535    };
1536    if raw_host.is_empty() {
1537        return None;
1538    }
1539    let port = port.unwrap_or(if scheme == "https" { 443 } else { 80 });
1540    let path = tail.split(['?', '#']).next().unwrap_or("");
1541    let segments = credential_path_segments(path)?;
1542    Some((scheme, normalize_host(raw_host), port, segments, userinfo))
1543}
1544
1545/// Decoded, non-empty path segments, or `None` when a segment is `.`/`..` (as written or
1546/// after decoding), decodes to text containing `/` or `\`, or carries a malformed escape.
1547fn credential_path_segments(path: &str) -> Option<Vec<String>> {
1548    let mut out = Vec::new();
1549    for raw in path.split('/') {
1550        if raw.is_empty() {
1551            continue;
1552        }
1553        let bytes = raw.as_bytes();
1554        let mut decoded = Vec::with_capacity(bytes.len());
1555        let mut i = 0;
1556        while i < bytes.len() {
1557            if bytes[i] == b'%' {
1558                let digits = bytes.get(i + 1..i + 3)?;
1559                if !digits.iter().all(u8::is_ascii_hexdigit) {
1560                    return None;
1561                }
1562                let text = std::str::from_utf8(digits).ok()?;
1563                decoded.push(u8::from_str_radix(text, 16).ok()?);
1564                i += 3;
1565            } else {
1566                decoded.push(bytes[i]);
1567                i += 1;
1568            }
1569        }
1570        let segment = String::from_utf8(decoded).ok()?;
1571        if segment == "." || segment == ".." || segment.contains(['/', '\\']) {
1572            return None;
1573        }
1574        // A second decoding layer must not smuggle a separator or traversal either
1575        // (`%252e%252e`, `a%252fb`): a server that decodes twice would see them.
1576        if second_layer_is_unsafe(&segment) {
1577            return None;
1578        }
1579        out.push(segment);
1580    }
1581    Some(out)
1582}
1583
1584/// True when decoding `segment` one more time would yield `.`/`..` or text containing
1585/// `/` or `\`. Only valid `%XX` escapes count; a bare `%` stays literal.
1586fn second_layer_is_unsafe(segment: &str) -> bool {
1587    let bytes = segment.as_bytes();
1588    let mut decoded = Vec::with_capacity(bytes.len());
1589    let mut i = 0;
1590    let mut any_escape = false;
1591    while i < bytes.len() {
1592        let escape = if bytes[i] == b'%' {
1593            bytes
1594                .get(i + 1..i + 3)
1595                .filter(|d| d.len() == 2 && d.iter().all(u8::is_ascii_hexdigit))
1596                .and_then(|d| std::str::from_utf8(d).ok())
1597                .and_then(|t| u8::from_str_radix(t, 16).ok())
1598        } else {
1599            None
1600        };
1601        match escape {
1602            Some(byte) => {
1603                any_escape = true;
1604                decoded.push(byte);
1605                i += 3;
1606            }
1607            None => {
1608                decoded.push(bytes[i]);
1609                i += 1;
1610            }
1611        }
1612    }
1613    if !any_escape {
1614        return false;
1615    }
1616    match String::from_utf8(decoded) {
1617        Ok(text) => text == "." || text == ".." || text.contains(['/', '\\']),
1618        Err(_) => true,
1619    }
1620}
1621
1622/// Why [`validate_public_https_url`] refused a URL.
1623#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1624#[non_exhaustive]
1625pub enum UrlPolicyError {
1626    /// The URL is not `https://`, or it has no `scheme://` separator.
1627    NotHttps,
1628    /// The authority carries userinfo (`user:pass@host`).
1629    Credentials,
1630    /// The host is empty.
1631    NoHost,
1632    /// The host is a non-public address, a local or internal name, or a single-label name.
1633    NonPublicHost,
1634    /// The authority or host is not a shape this check accepts (a legacy IPv4 spelling such as
1635    /// `0x7f.1`, or an unclosed IPv6 bracket).
1636    Malformed,
1637}
1638
1639impl fmt::Display for UrlPolicyError {
1640    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1641        f.write_str(match self {
1642            Self::NotHttps => "URL must use https",
1643            Self::Credentials => "URL must not carry credentials",
1644            Self::NoHost => "URL has no host",
1645            Self::NonPublicHost => "URL host is not a public address",
1646            Self::Malformed => "URL is malformed",
1647        })
1648    }
1649}
1650
1651impl Error for UrlPolicyError {}
1652
1653/// Check that `url` is a plain public `https` URL: scheme `https` (any case), no userinfo, a
1654/// host that is a public IP literal or a multi-label name, and no `localhost`, `.localhost`,
1655/// `.local` or `.internal` name. The check reads the string only; it does not resolve the host.
1656/// The SSRF guard of [`crate::Client`] still vets the resolved addresses on every request.
1657/// Query strings and fragments are allowed and not inspected.
1658pub fn validate_public_https_url(url: &str) -> Result<(), UrlPolicyError> {
1659    let (scheme, rest) = url.split_once("://").ok_or(UrlPolicyError::NotHttps)?;
1660    if !scheme.eq_ignore_ascii_case("https") {
1661        return Err(UrlPolicyError::NotHttps);
1662    }
1663    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1664    let authority = &rest[..end];
1665    if authority.contains('@') {
1666        return Err(UrlPolicyError::Credentials);
1667    }
1668    // Split the authority into host and optional port first. A bracketed host is an IPv6
1669    // literal; anything else splits at the last colon.
1670    let (bracketed, raw_host, port) = if let Some(inner) = authority.strip_prefix('[') {
1671        let (literal, after) = inner.split_once(']').ok_or(UrlPolicyError::Malformed)?;
1672        let port = match after {
1673            "" => None,
1674            _ => Some(after.strip_prefix(':').ok_or(UrlPolicyError::Malformed)?),
1675        };
1676        (true, literal, port)
1677    } else {
1678        match authority.rsplit_once(':') {
1679            Some((host, port)) => (false, host, Some(port)),
1680            None => (false, authority, None),
1681        }
1682    };
1683    if let Some(port) = port {
1684        validate_url_port(port)?;
1685    }
1686    if bracketed {
1687        let addr = raw_host
1688            .parse::<Ipv6Addr>()
1689            .map_err(|_| UrlPolicyError::Malformed)?;
1690        return if classify_ip(IpAddr::V6(addr)) == AddressClass::Public {
1691            Ok(())
1692        } else {
1693            Err(UrlPolicyError::NonPublicHost)
1694        };
1695    }
1696    // Normalize before any classification: decode percent escapes, lowercase, and drop the
1697    // trailing root dots, so `localhost.` and `%31%32%37.0.0.1` cannot pass as public names.
1698    // Delimiters are rejected here, for unbracketed text only.
1699    let host = normalize_url_host(raw_host)?;
1700    if let Some(parsed) = parse_host_ip(&host) {
1701        if parsed.kind == HostIpKind::Ipv4Legacy {
1702            return Err(UrlPolicyError::Malformed);
1703        }
1704        return if classify_ip(parsed.addr) == AddressClass::Public {
1705            Ok(())
1706        } else {
1707            Err(UrlPolicyError::NonPublicHost)
1708        };
1709    }
1710    let local_name = host == "localhost"
1711        || host.ends_with(".localhost")
1712        || host.ends_with(".local")
1713        || host.ends_with(".internal");
1714    if local_name || !host.contains('.') {
1715        return Err(UrlPolicyError::NonPublicHost);
1716    }
1717    Ok(())
1718}
1719
1720/// Accept an empty port (the scheme default) or a decimal port in `1..=65535`.
1721fn validate_url_port(port: &str) -> Result<(), UrlPolicyError> {
1722    if port.is_empty() {
1723        return Ok(());
1724    }
1725    if !port.bytes().all(|b| b.is_ascii_digit()) {
1726        return Err(UrlPolicyError::Malformed);
1727    }
1728    match port.parse::<u16>() {
1729        Ok(0) | Err(_) => Err(UrlPolicyError::Malformed),
1730        Ok(_) => Ok(()),
1731    }
1732}
1733
1734/// Percent-decode a URL host, lowercase it, and strip trailing root dots. Escapes must be
1735/// two hex digits and the decoded host must not contain delimiters, controls or whitespace.
1736fn normalize_url_host(raw: &str) -> Result<String, UrlPolicyError> {
1737    let bytes = raw.as_bytes();
1738    let mut decoded = Vec::with_capacity(bytes.len());
1739    let mut i = 0;
1740    while i < bytes.len() {
1741        if bytes[i] == b'%' {
1742            let digits = bytes.get(i + 1..i + 3).ok_or(UrlPolicyError::Malformed)?;
1743            if !digits.iter().all(u8::is_ascii_hexdigit) {
1744                return Err(UrlPolicyError::Malformed);
1745            }
1746            let text = std::str::from_utf8(digits).map_err(|_| UrlPolicyError::Malformed)?;
1747            decoded.push(u8::from_str_radix(text, 16).map_err(|_| UrlPolicyError::Malformed)?);
1748            i += 3;
1749        } else {
1750            decoded.push(bytes[i]);
1751            i += 1;
1752        }
1753    }
1754    let text = String::from_utf8(decoded).map_err(|_| UrlPolicyError::Malformed)?;
1755    if text.chars().any(|c| {
1756        c.is_control()
1757            || c.is_whitespace()
1758            || matches!(c, '/' | '\\' | '?' | '#' | '@' | '%' | '[' | ']' | ':')
1759    }) {
1760        return Err(UrlPolicyError::Malformed);
1761    }
1762    let host = text.to_ascii_lowercase();
1763    let host = host.trim_end_matches('.');
1764    if host.is_empty() {
1765        return Err(UrlPolicyError::NoHost);
1766    }
1767    Ok(host.to_string())
1768}
1769
1770#[cfg(test)]
1771mod public_url_tests {
1772    use super::*;
1773
1774    #[test]
1775    fn plain_public_https_urls_pass() {
1776        assert_eq!(
1777            validate_public_https_url("https://example.com/path?q=1#frag"),
1778            Ok(())
1779        );
1780        assert_eq!(
1781            validate_public_https_url("HTTPS://Example.COM:8443"),
1782            Ok(())
1783        );
1784        assert_eq!(validate_public_https_url("https://93.184.216.34/x"), Ok(()));
1785        assert_eq!(
1786            validate_public_https_url("https://[2606:4700::1111]/"),
1787            Ok(())
1788        );
1789    }
1790
1791    #[test]
1792    fn non_https_and_credentials_are_refused() {
1793        assert_eq!(
1794            validate_public_https_url("http://example.com"),
1795            Err(UrlPolicyError::NotHttps)
1796        );
1797        assert_eq!(
1798            validate_public_https_url("example.com"),
1799            Err(UrlPolicyError::NotHttps)
1800        );
1801        assert_eq!(
1802            validate_public_https_url("https://user:pw@example.com"),
1803            Err(UrlPolicyError::Credentials)
1804        );
1805    }
1806
1807    #[test]
1808    fn local_and_private_hosts_are_refused() {
1809        for url in [
1810            "https://localhost/",
1811            "https://api.localhost/",
1812            "https://printer.local/",
1813            "https://db.internal/",
1814            "https://intranet/",
1815            "https://127.0.0.1/",
1816            "https://10.0.0.5/",
1817            "https://169.254.169.254/latest",
1818            "https://[::1]/",
1819            "https://[fd00::1]/",
1820        ] {
1821            assert_eq!(
1822                validate_public_https_url(url),
1823                Err(UrlPolicyError::NonPublicHost),
1824                "{url}"
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    fn legacy_ipv4_spellings_and_empty_hosts_are_refused() {
1831        assert_eq!(
1832            validate_public_https_url("https://0x7f.1/"),
1833            Err(UrlPolicyError::Malformed)
1834        );
1835        assert_eq!(
1836            validate_public_https_url("https:///path"),
1837            Err(UrlPolicyError::NoHost)
1838        );
1839        assert_eq!(
1840            validate_public_https_url("https://[::1/"),
1841            Err(UrlPolicyError::Malformed)
1842        );
1843    }
1844
1845    #[test]
1846    fn trailing_dot_local_names_are_refused() {
1847        for url in [
1848            "https://localhost./",
1849            "https://LOCALHOST../x",
1850            "https://printer.local./",
1851            "https://db.internal./",
1852            "https://api.localhost./",
1853            "https://intranet./",
1854            "https://127.0.0.1./",
1855        ] {
1856            assert_eq!(
1857                validate_public_https_url(url),
1858                Err(UrlPolicyError::NonPublicHost),
1859                "{url}"
1860            );
1861        }
1862    }
1863
1864    #[test]
1865    fn percent_encoded_hosts_are_decoded_before_classification() {
1866        assert_eq!(
1867            validate_public_https_url("https://%31%32%37.0.0.1/"),
1868            Err(UrlPolicyError::NonPublicHost)
1869        );
1870        assert_eq!(
1871            validate_public_https_url("https://%31%32%37.0.0.1./"),
1872            Err(UrlPolicyError::NonPublicHost)
1873        );
1874        assert_eq!(
1875            validate_public_https_url("https://%6c%6f%63%61%6c%68%6f%73%74/"),
1876            Err(UrlPolicyError::NonPublicHost)
1877        );
1878        assert_eq!(
1879            validate_public_https_url("https://%2fexample.com/"),
1880            Err(UrlPolicyError::Malformed)
1881        );
1882        assert_eq!(
1883            validate_public_https_url("https://exa%3Fmple.com/"),
1884            Err(UrlPolicyError::Malformed)
1885        );
1886        assert_eq!(
1887            validate_public_https_url("https://example%2.com/"),
1888            Err(UrlPolicyError::Malformed)
1889        );
1890        assert_eq!(
1891            validate_public_https_url("https://%+1.example.com/"),
1892            Err(UrlPolicyError::Malformed)
1893        );
1894        assert_eq!(
1895            validate_public_https_url("https://%25.example.com/"),
1896            Err(UrlPolicyError::Malformed)
1897        );
1898    }
1899
1900    #[test]
1901    fn trailing_dot_public_names_and_valid_ports_pass() {
1902        assert_eq!(validate_public_https_url("https://example.com./x"), Ok(()));
1903        assert_eq!(
1904            validate_public_https_url("https://example.com:443/"),
1905            Ok(())
1906        );
1907        assert_eq!(validate_public_https_url("https://example.com:/"), Ok(()));
1908        assert_eq!(
1909            validate_public_https_url("https://[2606:4700::1111]:8443/"),
1910            Ok(())
1911        );
1912    }
1913
1914    #[test]
1915    fn invalid_ports_are_refused() {
1916        for url in [
1917            "https://example.com:0/",
1918            "https://example.com:65536/",
1919            "https://example.com:abc/",
1920            "https://example.com:+443/",
1921            "https://example.com:80:90/",
1922            "https://[2606:4700::1111]:0/",
1923            "https://[2606:4700::1111]x/",
1924        ] {
1925            assert_eq!(
1926                validate_public_https_url(url),
1927                Err(UrlPolicyError::Malformed),
1928                "{url}"
1929            );
1930        }
1931    }
1932}