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/// Why [`validate_public_https_url`] refused a URL.
1444#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
1445#[non_exhaustive]
1446pub enum UrlPolicyError {
1447    /// The URL is not `https://`, or it has no `scheme://` separator.
1448    NotHttps,
1449    /// The authority carries userinfo (`user:pass@host`).
1450    Credentials,
1451    /// The host is empty.
1452    NoHost,
1453    /// The host is a non-public address, a local or internal name, or a single-label name.
1454    NonPublicHost,
1455    /// The authority or host is not a shape this check accepts (a legacy IPv4 spelling such as
1456    /// `0x7f.1`, or an unclosed IPv6 bracket).
1457    Malformed,
1458}
1459
1460impl fmt::Display for UrlPolicyError {
1461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1462        f.write_str(match self {
1463            Self::NotHttps => "URL must use https",
1464            Self::Credentials => "URL must not carry credentials",
1465            Self::NoHost => "URL has no host",
1466            Self::NonPublicHost => "URL host is not a public address",
1467            Self::Malformed => "URL is malformed",
1468        })
1469    }
1470}
1471
1472impl Error for UrlPolicyError {}
1473
1474/// Check that `url` is a plain public `https` URL: scheme `https` (any case), no userinfo, a
1475/// host that is a public IP literal or a multi-label name, and no `localhost`, `.localhost`,
1476/// `.local` or `.internal` name. The check reads the string only; it does not resolve the host.
1477/// The SSRF guard of [`crate::Client`] still vets the resolved addresses on every request.
1478/// Query strings and fragments are allowed and not inspected.
1479pub fn validate_public_https_url(url: &str) -> Result<(), UrlPolicyError> {
1480    let (scheme, rest) = url.split_once("://").ok_or(UrlPolicyError::NotHttps)?;
1481    if !scheme.eq_ignore_ascii_case("https") {
1482        return Err(UrlPolicyError::NotHttps);
1483    }
1484    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1485    let authority = &rest[..end];
1486    if authority.contains('@') {
1487        return Err(UrlPolicyError::Credentials);
1488    }
1489    // Split the authority into host and optional port first. A bracketed host is an IPv6
1490    // literal; anything else splits at the last colon.
1491    let (bracketed, raw_host, port) = if let Some(inner) = authority.strip_prefix('[') {
1492        let (literal, after) = inner.split_once(']').ok_or(UrlPolicyError::Malformed)?;
1493        let port = match after {
1494            "" => None,
1495            _ => Some(after.strip_prefix(':').ok_or(UrlPolicyError::Malformed)?),
1496        };
1497        (true, literal, port)
1498    } else {
1499        match authority.rsplit_once(':') {
1500            Some((host, port)) => (false, host, Some(port)),
1501            None => (false, authority, None),
1502        }
1503    };
1504    if let Some(port) = port {
1505        validate_url_port(port)?;
1506    }
1507    if bracketed {
1508        let addr = raw_host
1509            .parse::<Ipv6Addr>()
1510            .map_err(|_| UrlPolicyError::Malformed)?;
1511        return if classify_ip(IpAddr::V6(addr)) == AddressClass::Public {
1512            Ok(())
1513        } else {
1514            Err(UrlPolicyError::NonPublicHost)
1515        };
1516    }
1517    // Normalize before any classification: decode percent escapes, lowercase, and drop the
1518    // trailing root dots, so `localhost.` and `%31%32%37.0.0.1` cannot pass as public names.
1519    // Delimiters are rejected here, for unbracketed text only.
1520    let host = normalize_url_host(raw_host)?;
1521    if let Some(parsed) = parse_host_ip(&host) {
1522        if parsed.kind == HostIpKind::Ipv4Legacy {
1523            return Err(UrlPolicyError::Malformed);
1524        }
1525        return if classify_ip(parsed.addr) == AddressClass::Public {
1526            Ok(())
1527        } else {
1528            Err(UrlPolicyError::NonPublicHost)
1529        };
1530    }
1531    let local_name = host == "localhost"
1532        || host.ends_with(".localhost")
1533        || host.ends_with(".local")
1534        || host.ends_with(".internal");
1535    if local_name || !host.contains('.') {
1536        return Err(UrlPolicyError::NonPublicHost);
1537    }
1538    Ok(())
1539}
1540
1541/// Accept an empty port (the scheme default) or a decimal port in `1..=65535`.
1542fn validate_url_port(port: &str) -> Result<(), UrlPolicyError> {
1543    if port.is_empty() {
1544        return Ok(());
1545    }
1546    if !port.bytes().all(|b| b.is_ascii_digit()) {
1547        return Err(UrlPolicyError::Malformed);
1548    }
1549    match port.parse::<u16>() {
1550        Ok(0) | Err(_) => Err(UrlPolicyError::Malformed),
1551        Ok(_) => Ok(()),
1552    }
1553}
1554
1555/// Percent-decode a URL host, lowercase it, and strip trailing root dots. Escapes must be
1556/// two hex digits and the decoded host must not contain delimiters, controls or whitespace.
1557fn normalize_url_host(raw: &str) -> Result<String, UrlPolicyError> {
1558    let bytes = raw.as_bytes();
1559    let mut decoded = Vec::with_capacity(bytes.len());
1560    let mut i = 0;
1561    while i < bytes.len() {
1562        if bytes[i] == b'%' {
1563            let digits = bytes.get(i + 1..i + 3).ok_or(UrlPolicyError::Malformed)?;
1564            if !digits.iter().all(u8::is_ascii_hexdigit) {
1565                return Err(UrlPolicyError::Malformed);
1566            }
1567            let text = std::str::from_utf8(digits).map_err(|_| UrlPolicyError::Malformed)?;
1568            decoded.push(u8::from_str_radix(text, 16).map_err(|_| UrlPolicyError::Malformed)?);
1569            i += 3;
1570        } else {
1571            decoded.push(bytes[i]);
1572            i += 1;
1573        }
1574    }
1575    let text = String::from_utf8(decoded).map_err(|_| UrlPolicyError::Malformed)?;
1576    if text.chars().any(|c| {
1577        c.is_control()
1578            || c.is_whitespace()
1579            || matches!(c, '/' | '\\' | '?' | '#' | '@' | '%' | '[' | ']' | ':')
1580    }) {
1581        return Err(UrlPolicyError::Malformed);
1582    }
1583    let host = text.to_ascii_lowercase();
1584    let host = host.trim_end_matches('.');
1585    if host.is_empty() {
1586        return Err(UrlPolicyError::NoHost);
1587    }
1588    Ok(host.to_string())
1589}
1590
1591#[cfg(test)]
1592mod public_url_tests {
1593    use super::*;
1594
1595    #[test]
1596    fn plain_public_https_urls_pass() {
1597        assert_eq!(
1598            validate_public_https_url("https://example.com/path?q=1#frag"),
1599            Ok(())
1600        );
1601        assert_eq!(
1602            validate_public_https_url("HTTPS://Example.COM:8443"),
1603            Ok(())
1604        );
1605        assert_eq!(validate_public_https_url("https://93.184.216.34/x"), Ok(()));
1606        assert_eq!(
1607            validate_public_https_url("https://[2606:4700::1111]/"),
1608            Ok(())
1609        );
1610    }
1611
1612    #[test]
1613    fn non_https_and_credentials_are_refused() {
1614        assert_eq!(
1615            validate_public_https_url("http://example.com"),
1616            Err(UrlPolicyError::NotHttps)
1617        );
1618        assert_eq!(
1619            validate_public_https_url("example.com"),
1620            Err(UrlPolicyError::NotHttps)
1621        );
1622        assert_eq!(
1623            validate_public_https_url("https://user:pw@example.com"),
1624            Err(UrlPolicyError::Credentials)
1625        );
1626    }
1627
1628    #[test]
1629    fn local_and_private_hosts_are_refused() {
1630        for url in [
1631            "https://localhost/",
1632            "https://api.localhost/",
1633            "https://printer.local/",
1634            "https://db.internal/",
1635            "https://intranet/",
1636            "https://127.0.0.1/",
1637            "https://10.0.0.5/",
1638            "https://169.254.169.254/latest",
1639            "https://[::1]/",
1640            "https://[fd00::1]/",
1641        ] {
1642            assert_eq!(
1643                validate_public_https_url(url),
1644                Err(UrlPolicyError::NonPublicHost),
1645                "{url}"
1646            );
1647        }
1648    }
1649
1650    #[test]
1651    fn legacy_ipv4_spellings_and_empty_hosts_are_refused() {
1652        assert_eq!(
1653            validate_public_https_url("https://0x7f.1/"),
1654            Err(UrlPolicyError::Malformed)
1655        );
1656        assert_eq!(
1657            validate_public_https_url("https:///path"),
1658            Err(UrlPolicyError::NoHost)
1659        );
1660        assert_eq!(
1661            validate_public_https_url("https://[::1/"),
1662            Err(UrlPolicyError::Malformed)
1663        );
1664    }
1665
1666    #[test]
1667    fn trailing_dot_local_names_are_refused() {
1668        for url in [
1669            "https://localhost./",
1670            "https://LOCALHOST../x",
1671            "https://printer.local./",
1672            "https://db.internal./",
1673            "https://api.localhost./",
1674            "https://intranet./",
1675            "https://127.0.0.1./",
1676        ] {
1677            assert_eq!(
1678                validate_public_https_url(url),
1679                Err(UrlPolicyError::NonPublicHost),
1680                "{url}"
1681            );
1682        }
1683    }
1684
1685    #[test]
1686    fn percent_encoded_hosts_are_decoded_before_classification() {
1687        assert_eq!(
1688            validate_public_https_url("https://%31%32%37.0.0.1/"),
1689            Err(UrlPolicyError::NonPublicHost)
1690        );
1691        assert_eq!(
1692            validate_public_https_url("https://%31%32%37.0.0.1./"),
1693            Err(UrlPolicyError::NonPublicHost)
1694        );
1695        assert_eq!(
1696            validate_public_https_url("https://%6c%6f%63%61%6c%68%6f%73%74/"),
1697            Err(UrlPolicyError::NonPublicHost)
1698        );
1699        assert_eq!(
1700            validate_public_https_url("https://%2fexample.com/"),
1701            Err(UrlPolicyError::Malformed)
1702        );
1703        assert_eq!(
1704            validate_public_https_url("https://exa%3Fmple.com/"),
1705            Err(UrlPolicyError::Malformed)
1706        );
1707        assert_eq!(
1708            validate_public_https_url("https://example%2.com/"),
1709            Err(UrlPolicyError::Malformed)
1710        );
1711        assert_eq!(
1712            validate_public_https_url("https://%+1.example.com/"),
1713            Err(UrlPolicyError::Malformed)
1714        );
1715        assert_eq!(
1716            validate_public_https_url("https://%25.example.com/"),
1717            Err(UrlPolicyError::Malformed)
1718        );
1719    }
1720
1721    #[test]
1722    fn trailing_dot_public_names_and_valid_ports_pass() {
1723        assert_eq!(validate_public_https_url("https://example.com./x"), Ok(()));
1724        assert_eq!(
1725            validate_public_https_url("https://example.com:443/"),
1726            Ok(())
1727        );
1728        assert_eq!(validate_public_https_url("https://example.com:/"), Ok(()));
1729        assert_eq!(
1730            validate_public_https_url("https://[2606:4700::1111]:8443/"),
1731            Ok(())
1732        );
1733    }
1734
1735    #[test]
1736    fn invalid_ports_are_refused() {
1737        for url in [
1738            "https://example.com:0/",
1739            "https://example.com:65536/",
1740            "https://example.com:abc/",
1741            "https://example.com:+443/",
1742            "https://example.com:80:90/",
1743            "https://[2606:4700::1111]:0/",
1744            "https://[2606:4700::1111]x/",
1745        ] {
1746            assert_eq!(
1747                validate_public_https_url(url),
1748                Err(UrlPolicyError::Malformed),
1749                "{url}"
1750            );
1751        }
1752    }
1753}