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    let p = url_authority_parts(url)?;
1504    let path = p.tail.split(['?', '#']).next().unwrap_or("");
1505    let segments = credential_path_segments(path)?;
1506    Some((p.scheme, p.host, p.port, segments, p.userinfo))
1507}
1508
1509/// Canonical origin and shape of an absolute http(s) URL string, before any path rules.
1510struct UrlAuthorityParts<'a> {
1511    scheme: String,
1512    /// Lowercased, brackets and one trailing dot removed (see `normalize_host`).
1513    host: String,
1514    port: u16,
1515    /// The authority carried `user[:password]@`.
1516    userinfo: bool,
1517    /// Everything from the first `/`, `?` or `#` after the authority (possibly empty).
1518    tail: &'a str,
1519}
1520
1521/// Split an absolute http(s) URL into its canonical origin, the userinfo flag and the rest.
1522/// `None` for another scheme, no host, whitespace/control/backslash anywhere, or a bad
1523/// port. Shared by [`CredentialScope`], [`ApprovedOrigins`] and the redirect shape check, so
1524/// all three read an origin the same way.
1525fn url_authority_parts(url: &str) -> Option<UrlAuthorityParts<'_>> {
1526    if url
1527        .chars()
1528        .any(|c| c.is_control() || c.is_whitespace() || c == '\\')
1529    {
1530        return None;
1531    }
1532    let (scheme, rest) = url.split_once("://")?;
1533    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
1534        return None;
1535    }
1536    let scheme = scheme.to_ascii_lowercase();
1537    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1538    let (authority, tail) = rest.split_at(end);
1539    let (host_port, userinfo) = match authority.rsplit_once('@') {
1540        Some((_, host_port)) => (host_port, true),
1541        None => (authority, false),
1542    };
1543    let (raw_host, port) = if let Some(inner) = host_port.strip_prefix('[') {
1544        let (literal, after) = inner.split_once(']')?;
1545        let port = match after {
1546            "" => None,
1547            _ => Some(after.strip_prefix(':')?.parse().ok()?),
1548        };
1549        (literal, port)
1550    } else if let Some((host, port)) = host_port.rsplit_once(':') {
1551        if port.is_empty() {
1552            (host, None)
1553        } else {
1554            (host, Some(port.parse().ok()?))
1555        }
1556    } else {
1557        (host_port, None)
1558    };
1559    if raw_host.is_empty() {
1560        return None;
1561    }
1562    let port = port.unwrap_or(if scheme == "https" { 443 } else { 80 });
1563    Some(UrlAuthorityParts {
1564        scheme,
1565        host: normalize_host(raw_host),
1566        port,
1567        userinfo,
1568        tail,
1569    })
1570}
1571
1572/// Canonical `(scheme, host, port)` of an absolute http(s) URL and whether its authority
1573/// carries userinfo; `None` when the string is not a URL this crate will follow.
1574pub(crate) fn url_origin_parts(url: &str) -> Option<((String, String, u16), bool)> {
1575    let parts = url_authority_parts(url)?;
1576    Some(((parts.scheme, parts.host, parts.port), parts.userinfo))
1577}
1578
1579/// The origins a request's redirects may reach besides the origin the request itself
1580/// started at, set with [`crate::Request::approved_redirect_origins`]. An origin is scheme,
1581/// host and port, compared canonically (scheme and host lowercased, one trailing dot
1582/// ignored, default ports folded), the same form [`CredentialScope`] and the redirect rules
1583/// use.
1584///
1585/// The request's own origin is always approved, so a same-origin redirect never needs an
1586/// entry; `ApprovedOrigins::new()` therefore means "this origin only". A redirect hop
1587/// outside the set is refused before anything is sent to it, with
1588/// [`crate::HttpError::RedirectRefused`] and [`RedirectRefusal::UnapprovedOrigin`].
1589///
1590/// The set decides where a redirect may go, not what it carries: a hop to another approved
1591/// origin still drops credential-named and secret-valued headers, and the address policy
1592/// still runs on every hop. A request without a set follows redirects to any origin the
1593/// address policy admits, as before.
1594#[derive(Clone, Debug, Default, PartialEq, Eq)]
1595pub struct ApprovedOrigins {
1596    origins: Vec<(String, String, u16)>,
1597}
1598
1599impl ApprovedOrigins {
1600    /// The request's own origin only.
1601    pub fn new() -> Self {
1602        Self::default()
1603    }
1604
1605    /// Parse origins such as `https://downloads.example.com` or
1606    /// `https://cdn.example.com:8443`. A single trailing `/` is accepted. Returns `None` when
1607    /// any entry is malformed, is not `http`/`https`, or carries userinfo, a path, a query
1608    /// or a fragment; no partial set is returned.
1609    pub fn parse<I, S>(origins: I) -> Option<Self>
1610    where
1611        I: IntoIterator<Item = S>,
1612        S: AsRef<str>,
1613    {
1614        let mut set = Self::new();
1615        for origin in origins {
1616            set = set.with_origin(origin.as_ref())?;
1617        }
1618        Some(set)
1619    }
1620
1621    /// Add one origin (same rules as [`ApprovedOrigins::parse`]); `None` when it is refused.
1622    pub fn with_origin(mut self, origin: &str) -> Option<Self> {
1623        let parts = url_authority_parts(origin)?;
1624        if parts.userinfo || !(parts.tail.is_empty() || parts.tail == "/") {
1625            return None;
1626        }
1627        let key = (parts.scheme, parts.host, parts.port);
1628        if !self.origins.contains(&key) {
1629            self.origins.push(key);
1630        }
1631        Some(self)
1632    }
1633
1634    /// True when `url` is at one of the listed origins. The request's own origin is not
1635    /// listed here; the clients add it when they check a hop.
1636    pub fn allows(&self, url: &str) -> bool {
1637        url_authority_parts(url).is_some_and(|parts| {
1638            self.origins
1639                .iter()
1640                .any(|(s, h, p)| *s == parts.scheme && *h == parts.host && *p == parts.port)
1641        })
1642    }
1643}
1644
1645/// Why a redirect target was refused by [`crate::HttpError::RedirectRefused`] or
1646/// [`check_redirect_target`].
1647#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1648#[non_exhaustive]
1649pub enum RedirectRefusal {
1650    /// The target's origin is neither the request's origin nor in its [`ApprovedOrigins`].
1651    UnapprovedOrigin,
1652    /// The redirect leaves `https` for `http`. Returned by [`check_redirect_target`] only;
1653    /// the clients report this refusal as [`crate::HttpError::AddressDenied`] (see
1654    /// [`AddressPolicy::allow_insecure_redirects`]).
1655    SchemeDowngrade,
1656    /// The target's authority carries userinfo (`user:pass@host`).
1657    Credentials,
1658    /// The target is not a shape this check accepts: another scheme, no host, a bad port, or
1659    /// whitespace, control characters or a backslash anywhere in the URL.
1660    Malformed,
1661}
1662
1663impl fmt::Display for RedirectRefusal {
1664    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1665        f.write_str(match self {
1666            Self::UnapprovedOrigin => "redirect target origin is not approved",
1667            Self::SchemeDowngrade => "redirect from https to http",
1668            Self::Credentials => "redirect target carries credentials",
1669            Self::Malformed => "redirect target is malformed",
1670        })
1671    }
1672}
1673
1674impl Error for RedirectRefusal {}
1675
1676/// String-level guard for one redirect hop, for transports that follow redirects
1677/// themselves. `request_url` is the URL the call started at (its origin is always approved),
1678/// `from_url` the URL that answered with the redirect and `location` its `Location` value.
1679/// Returns the absolute target (fragment dropped) when the hop is acceptable.
1680///
1681/// Refuses, in this order: a `Location` that does not resolve to an absolute http(s) URL, or
1682/// that holds whitespace, control characters or a backslash ([`RedirectRefusal::Malformed`]);
1683/// an `https` to `http` hop ([`RedirectRefusal::SchemeDowngrade`], no opt-in here, unlike
1684/// [`AddressPolicy::allow_insecure_redirects`] on the clients); a target with userinfo
1685/// ([`RedirectRefusal::Credentials`]); and a target outside the request's origin and
1686/// `approved` ([`RedirectRefusal::UnapprovedOrigin`]). It reads strings only and neither
1687/// resolves a host nor applies an [`AddressPolicy`]. The clients run the same shape and
1688/// origin rules on every hop.
1689pub fn check_redirect_target(
1690    request_url: &str,
1691    from_url: &str,
1692    location: &str,
1693    approved: &ApprovedOrigins,
1694) -> Result<String, RedirectRefusal> {
1695    let next =
1696        crate::redirect::resolve_location(from_url, location).ok_or(RedirectRefusal::Malformed)?;
1697    let from_scheme = crate::redirect::scheme_of(from_url).ok_or(RedirectRefusal::Malformed)?;
1698    url_origin_parts(&next).ok_or(RedirectRefusal::Malformed)?;
1699    let to_scheme = crate::redirect::scheme_of(&next).ok_or(RedirectRefusal::Malformed)?;
1700    if from_scheme == "https" && to_scheme == "http" {
1701        return Err(RedirectRefusal::SchemeDowngrade);
1702    }
1703    crate::redirect::vet_target(request_url, &next, Some(approved))?;
1704    Ok(next)
1705}
1706
1707/// Decoded, non-empty path segments, or `None` when a segment is `.`/`..` (as written or
1708/// after decoding), decodes to text containing `/` or `\`, or carries a malformed escape.
1709fn credential_path_segments(path: &str) -> Option<Vec<String>> {
1710    let mut out = Vec::new();
1711    for raw in path.split('/') {
1712        if raw.is_empty() {
1713            continue;
1714        }
1715        let bytes = raw.as_bytes();
1716        let mut decoded = Vec::with_capacity(bytes.len());
1717        let mut i = 0;
1718        while i < bytes.len() {
1719            if bytes[i] == b'%' {
1720                let digits = bytes.get(i + 1..i + 3)?;
1721                if !digits.iter().all(u8::is_ascii_hexdigit) {
1722                    return None;
1723                }
1724                let text = std::str::from_utf8(digits).ok()?;
1725                decoded.push(u8::from_str_radix(text, 16).ok()?);
1726                i += 3;
1727            } else {
1728                decoded.push(bytes[i]);
1729                i += 1;
1730            }
1731        }
1732        let segment = String::from_utf8(decoded).ok()?;
1733        if segment == "." || segment == ".." || segment.contains(['/', '\\']) {
1734            return None;
1735        }
1736        // A second decoding layer must not smuggle a separator or traversal either
1737        // (`%252e%252e`, `a%252fb`): a server that decodes twice would see them.
1738        if second_layer_is_unsafe(&segment) {
1739            return None;
1740        }
1741        out.push(segment);
1742    }
1743    Some(out)
1744}
1745
1746/// True when decoding `segment` one more time would yield `.`/`..` or text containing
1747/// `/` or `\`. Only valid `%XX` escapes count; a bare `%` stays literal.
1748fn second_layer_is_unsafe(segment: &str) -> bool {
1749    let bytes = segment.as_bytes();
1750    let mut decoded = Vec::with_capacity(bytes.len());
1751    let mut i = 0;
1752    let mut any_escape = false;
1753    while i < bytes.len() {
1754        let escape = if bytes[i] == b'%' {
1755            bytes
1756                .get(i + 1..i + 3)
1757                .filter(|d| d.len() == 2 && d.iter().all(u8::is_ascii_hexdigit))
1758                .and_then(|d| std::str::from_utf8(d).ok())
1759                .and_then(|t| u8::from_str_radix(t, 16).ok())
1760        } else {
1761            None
1762        };
1763        match escape {
1764            Some(byte) => {
1765                any_escape = true;
1766                decoded.push(byte);
1767                i += 3;
1768            }
1769            None => {
1770                decoded.push(bytes[i]);
1771                i += 1;
1772            }
1773        }
1774    }
1775    if !any_escape {
1776        return false;
1777    }
1778    match String::from_utf8(decoded) {
1779        Ok(text) => text == "." || text == ".." || text.contains(['/', '\\']),
1780        Err(_) => true,
1781    }
1782}
1783
1784/// Why [`validate_public_https_url`] refused a URL.
1785#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1786#[non_exhaustive]
1787pub enum UrlPolicyError {
1788    /// The URL is not `https://`, or it has no `scheme://` separator.
1789    NotHttps,
1790    /// The authority carries userinfo (`user:pass@host`).
1791    Credentials,
1792    /// The host is empty.
1793    NoHost,
1794    /// The host is a non-public address, a local or internal name, or a single-label name.
1795    NonPublicHost,
1796    /// The authority or host is not a shape this check accepts (a legacy IPv4 spelling such as
1797    /// `0x7f.1`, or an unclosed IPv6 bracket).
1798    Malformed,
1799}
1800
1801impl fmt::Display for UrlPolicyError {
1802    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1803        f.write_str(match self {
1804            Self::NotHttps => "URL must use https",
1805            Self::Credentials => "URL must not carry credentials",
1806            Self::NoHost => "URL has no host",
1807            Self::NonPublicHost => "URL host is not a public address",
1808            Self::Malformed => "URL is malformed",
1809        })
1810    }
1811}
1812
1813impl Error for UrlPolicyError {}
1814
1815/// Check that `url` is a plain public `https` URL: scheme `https` (any case), no userinfo, a
1816/// host that is a public IP literal or a multi-label name, and no `localhost`, `.localhost`,
1817/// `.local` or `.internal` name. The check reads the string only; it does not resolve the host.
1818/// The SSRF guard of [`crate::Client`] still vets the resolved addresses on every request.
1819/// Query strings and fragments are allowed and not inspected.
1820pub fn validate_public_https_url(url: &str) -> Result<(), UrlPolicyError> {
1821    let (scheme, rest) = url.split_once("://").ok_or(UrlPolicyError::NotHttps)?;
1822    if !scheme.eq_ignore_ascii_case("https") {
1823        return Err(UrlPolicyError::NotHttps);
1824    }
1825    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1826    let authority = &rest[..end];
1827    if authority.contains('@') {
1828        return Err(UrlPolicyError::Credentials);
1829    }
1830    // Split the authority into host and optional port first. A bracketed host is an IPv6
1831    // literal; anything else splits at the last colon.
1832    let (bracketed, raw_host, port) = if let Some(inner) = authority.strip_prefix('[') {
1833        let (literal, after) = inner.split_once(']').ok_or(UrlPolicyError::Malformed)?;
1834        let port = match after {
1835            "" => None,
1836            _ => Some(after.strip_prefix(':').ok_or(UrlPolicyError::Malformed)?),
1837        };
1838        (true, literal, port)
1839    } else {
1840        match authority.rsplit_once(':') {
1841            Some((host, port)) => (false, host, Some(port)),
1842            None => (false, authority, None),
1843        }
1844    };
1845    if let Some(port) = port {
1846        validate_url_port(port)?;
1847    }
1848    if bracketed {
1849        let addr = raw_host
1850            .parse::<Ipv6Addr>()
1851            .map_err(|_| UrlPolicyError::Malformed)?;
1852        return if classify_ip(IpAddr::V6(addr)) == AddressClass::Public {
1853            Ok(())
1854        } else {
1855            Err(UrlPolicyError::NonPublicHost)
1856        };
1857    }
1858    // Normalize before any classification: decode percent escapes, lowercase, and drop the
1859    // trailing root dots, so `localhost.` and `%31%32%37.0.0.1` cannot pass as public names.
1860    // Delimiters are rejected here, for unbracketed text only.
1861    let host = normalize_url_host(raw_host)?;
1862    if let Some(parsed) = parse_host_ip(&host) {
1863        if parsed.kind == HostIpKind::Ipv4Legacy {
1864            return Err(UrlPolicyError::Malformed);
1865        }
1866        return if classify_ip(parsed.addr) == AddressClass::Public {
1867            Ok(())
1868        } else {
1869            Err(UrlPolicyError::NonPublicHost)
1870        };
1871    }
1872    let local_name = host == "localhost"
1873        || host.ends_with(".localhost")
1874        || host.ends_with(".local")
1875        || host.ends_with(".internal");
1876    if local_name || !host.contains('.') {
1877        return Err(UrlPolicyError::NonPublicHost);
1878    }
1879    Ok(())
1880}
1881
1882/// Accept an empty port (the scheme default) or a decimal port in `1..=65535`.
1883fn validate_url_port(port: &str) -> Result<(), UrlPolicyError> {
1884    if port.is_empty() {
1885        return Ok(());
1886    }
1887    if !port.bytes().all(|b| b.is_ascii_digit()) {
1888        return Err(UrlPolicyError::Malformed);
1889    }
1890    match port.parse::<u16>() {
1891        Ok(0) | Err(_) => Err(UrlPolicyError::Malformed),
1892        Ok(_) => Ok(()),
1893    }
1894}
1895
1896/// Percent-decode a URL host, lowercase it, and strip trailing root dots. Escapes must be
1897/// two hex digits and the decoded host must not contain delimiters, controls or whitespace.
1898fn normalize_url_host(raw: &str) -> Result<String, UrlPolicyError> {
1899    let bytes = raw.as_bytes();
1900    let mut decoded = Vec::with_capacity(bytes.len());
1901    let mut i = 0;
1902    while i < bytes.len() {
1903        if bytes[i] == b'%' {
1904            let digits = bytes.get(i + 1..i + 3).ok_or(UrlPolicyError::Malformed)?;
1905            if !digits.iter().all(u8::is_ascii_hexdigit) {
1906                return Err(UrlPolicyError::Malformed);
1907            }
1908            let text = std::str::from_utf8(digits).map_err(|_| UrlPolicyError::Malformed)?;
1909            decoded.push(u8::from_str_radix(text, 16).map_err(|_| UrlPolicyError::Malformed)?);
1910            i += 3;
1911        } else {
1912            decoded.push(bytes[i]);
1913            i += 1;
1914        }
1915    }
1916    let text = String::from_utf8(decoded).map_err(|_| UrlPolicyError::Malformed)?;
1917    if text.chars().any(|c| {
1918        c.is_control()
1919            || c.is_whitespace()
1920            || matches!(c, '/' | '\\' | '?' | '#' | '@' | '%' | '[' | ']' | ':')
1921    }) {
1922        return Err(UrlPolicyError::Malformed);
1923    }
1924    let host = text.to_ascii_lowercase();
1925    let host = host.trim_end_matches('.');
1926    if host.is_empty() {
1927        return Err(UrlPolicyError::NoHost);
1928    }
1929    Ok(host.to_string())
1930}
1931
1932#[cfg(test)]
1933mod public_url_tests {
1934    use super::*;
1935
1936    #[test]
1937    fn plain_public_https_urls_pass() {
1938        assert_eq!(
1939            validate_public_https_url("https://example.com/path?q=1#frag"),
1940            Ok(())
1941        );
1942        assert_eq!(
1943            validate_public_https_url("HTTPS://Example.COM:8443"),
1944            Ok(())
1945        );
1946        assert_eq!(validate_public_https_url("https://93.184.216.34/x"), Ok(()));
1947        assert_eq!(
1948            validate_public_https_url("https://[2606:4700::1111]/"),
1949            Ok(())
1950        );
1951    }
1952
1953    #[test]
1954    fn non_https_and_credentials_are_refused() {
1955        assert_eq!(
1956            validate_public_https_url("http://example.com"),
1957            Err(UrlPolicyError::NotHttps)
1958        );
1959        assert_eq!(
1960            validate_public_https_url("example.com"),
1961            Err(UrlPolicyError::NotHttps)
1962        );
1963        assert_eq!(
1964            validate_public_https_url("https://user:pw@example.com"),
1965            Err(UrlPolicyError::Credentials)
1966        );
1967    }
1968
1969    #[test]
1970    fn local_and_private_hosts_are_refused() {
1971        for url in [
1972            "https://localhost/",
1973            "https://api.localhost/",
1974            "https://printer.local/",
1975            "https://db.internal/",
1976            "https://intranet/",
1977            "https://127.0.0.1/",
1978            "https://10.0.0.5/",
1979            "https://169.254.169.254/latest",
1980            "https://[::1]/",
1981            "https://[fd00::1]/",
1982        ] {
1983            assert_eq!(
1984                validate_public_https_url(url),
1985                Err(UrlPolicyError::NonPublicHost),
1986                "{url}"
1987            );
1988        }
1989    }
1990
1991    #[test]
1992    fn legacy_ipv4_spellings_and_empty_hosts_are_refused() {
1993        assert_eq!(
1994            validate_public_https_url("https://0x7f.1/"),
1995            Err(UrlPolicyError::Malformed)
1996        );
1997        assert_eq!(
1998            validate_public_https_url("https:///path"),
1999            Err(UrlPolicyError::NoHost)
2000        );
2001        assert_eq!(
2002            validate_public_https_url("https://[::1/"),
2003            Err(UrlPolicyError::Malformed)
2004        );
2005    }
2006
2007    #[test]
2008    fn trailing_dot_local_names_are_refused() {
2009        for url in [
2010            "https://localhost./",
2011            "https://LOCALHOST../x",
2012            "https://printer.local./",
2013            "https://db.internal./",
2014            "https://api.localhost./",
2015            "https://intranet./",
2016            "https://127.0.0.1./",
2017        ] {
2018            assert_eq!(
2019                validate_public_https_url(url),
2020                Err(UrlPolicyError::NonPublicHost),
2021                "{url}"
2022            );
2023        }
2024    }
2025
2026    #[test]
2027    fn percent_encoded_hosts_are_decoded_before_classification() {
2028        assert_eq!(
2029            validate_public_https_url("https://%31%32%37.0.0.1/"),
2030            Err(UrlPolicyError::NonPublicHost)
2031        );
2032        assert_eq!(
2033            validate_public_https_url("https://%31%32%37.0.0.1./"),
2034            Err(UrlPolicyError::NonPublicHost)
2035        );
2036        assert_eq!(
2037            validate_public_https_url("https://%6c%6f%63%61%6c%68%6f%73%74/"),
2038            Err(UrlPolicyError::NonPublicHost)
2039        );
2040        assert_eq!(
2041            validate_public_https_url("https://%2fexample.com/"),
2042            Err(UrlPolicyError::Malformed)
2043        );
2044        assert_eq!(
2045            validate_public_https_url("https://exa%3Fmple.com/"),
2046            Err(UrlPolicyError::Malformed)
2047        );
2048        assert_eq!(
2049            validate_public_https_url("https://example%2.com/"),
2050            Err(UrlPolicyError::Malformed)
2051        );
2052        assert_eq!(
2053            validate_public_https_url("https://%+1.example.com/"),
2054            Err(UrlPolicyError::Malformed)
2055        );
2056        assert_eq!(
2057            validate_public_https_url("https://%25.example.com/"),
2058            Err(UrlPolicyError::Malformed)
2059        );
2060    }
2061
2062    #[test]
2063    fn trailing_dot_public_names_and_valid_ports_pass() {
2064        assert_eq!(validate_public_https_url("https://example.com./x"), Ok(()));
2065        assert_eq!(
2066            validate_public_https_url("https://example.com:443/"),
2067            Ok(())
2068        );
2069        assert_eq!(validate_public_https_url("https://example.com:/"), Ok(()));
2070        assert_eq!(
2071            validate_public_https_url("https://[2606:4700::1111]:8443/"),
2072            Ok(())
2073        );
2074    }
2075
2076    #[test]
2077    fn invalid_ports_are_refused() {
2078        for url in [
2079            "https://example.com:0/",
2080            "https://example.com:65536/",
2081            "https://example.com:abc/",
2082            "https://example.com:+443/",
2083            "https://example.com:80:90/",
2084            "https://[2606:4700::1111]:0/",
2085            "https://[2606:4700::1111]x/",
2086        ] {
2087            assert_eq!(
2088                validate_public_https_url(url),
2089                Err(UrlPolicyError::Malformed),
2090                "{url}"
2091            );
2092        }
2093    }
2094}