1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
//! Which hosts an outbound probe must never be pointed at.
//!
//! **Keep in sync with `affinidi-did-web`** (`src/lib.rs`, `host_is_blocked` /
//! `ip_is_blocked`; copied from 0.1.4). That crate already guards DID
//! *resolution* and exports the connect-time half of the check
//! ([`affinidi_did_web::guarded_dns_resolver`]), but keeps this classifier
//! private. Replace this module with the exported API once one is published, so
//! resolution and probing enforce one policy rather than two copies of it.
//!
//! This classifies a host **as written in the URL**. A hostname that resolves
//! to one of these addresses is refused separately, at connect time, by the DNS
//! resolver the probe client installs.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
/// Whether `host` is one an outbound request must refuse: loopback, RFC-1918 /
/// unique-local, carrier-grade NAT, link-local (which includes the
/// cloud-metadata address `169.254.169.254`), unspecified, or broadcast. Bare
/// `localhost` and `*.localhost` / `*.local` are refused by name.
///
/// Takes the host as `reqwest::Url::host_str` returns it: already WHATWG
/// canonicalised (so `2130706433` and `0x7f.1` arrive as `127.0.0.1`), with
/// IPv6 literals in brackets.
pub(crate) fn is_blocked_host(host: &str) -> bool {
// A trailing root dot is part of a legal hostname and survives URL
// normalisation, so `localhost.` must normalise to `localhost` before the
// name comparisons -- otherwise it walks straight through this guard.
let h = host
.trim_end_matches('.')
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if h == "localhost" || h.ends_with(".localhost") || h.ends_with(".local") {
return true;
}
match h.parse::<IpAddr>() {
Ok(a) => is_blocked_ip(a),
Err(_) => false,
}
}
/// Whether an address must never be connected to.
fn is_blocked_ip(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(a) => ipv4_is_blocked(a),
IpAddr::V6(a) => ipv6_is_blocked(a),
}
}
fn ipv4_is_blocked(a: Ipv4Addr) -> bool {
let o = a.octets();
a.is_loopback()
|| a.is_private()
|| a.is_link_local() // 169.254.0.0/16 — covers cloud metadata 169.254.169.254
|| a.is_broadcast()
|| o[0] == 0 // 0.0.0.0/8 "this network" — includes the unspecified address
// 100.64.0.0/10 carrier-grade NAT: the Alibaba/Oracle metadata address
// 100.100.100.200 lives here, as do the node/pod CIDRs of many
// Kubernetes deployments. `Ipv4Addr::is_shared` is still unstable.
|| (o[0] == 100 && (64..128).contains(&o[1]))
|| (o[0] == 192 && o[1] == 0 && o[2] == 0) // 192.0.0.0/24 IETF protocol assignments
|| (o[0] == 198 && (o[1] & 0xfe) == 18) // 198.18.0.0/15 benchmarking
}
fn ipv6_is_blocked(a: Ipv6Addr) -> bool {
if a.is_loopback() || a.is_unspecified() {
return true;
}
// Both IPv4-mapped (::ffff:a.b.c.d) and the deprecated IPv4-compatible
// (::a.b.c.d) forms reach the same v4 target on stacks that still route
// them, and `to_ipv4` — unlike `to_ipv4_mapped` — covers both.
if let Some(v4) = a.to_ipv4() {
return ipv4_is_blocked(v4);
}
let s = a.segments();
// NAT64 well-known prefix 64:ff9b::/96 embeds a v4 destination in the low
// 32 bits, so classify that destination.
if s[0] == 0x0064 && s[1] == 0xff9b && s[2..6] == [0, 0, 0, 0] {
let v4 = Ipv4Addr::from((u32::from(s[6]) << 16) | u32::from(s[7]));
return ipv4_is_blocked(v4);
}
(s[0] & 0xfe00) == 0xfc00 // unique-local fc00::/7
|| (s[0] & 0xffc0) == 0xfe80 // link-local fe80::/10
|| (s[0] & 0xffc0) == 0xfec0 // deprecated site-local fec0::/10
}
#[cfg(test)]
mod tests {
use super::*;
// Vectors copied from `affinidi-did-web` 0.1.4 — keep in sync with it.
#[test]
fn host_guard_blocks_internal_and_metadata() {
for h in [
"127.0.0.1",
"0.0.0.0",
"0.1.2.3", // 0.0.0.0/8 "this network"
"10.0.0.5",
"172.16.9.9",
"192.168.1.1",
"169.254.169.254", // cloud metadata
"100.100.100.200", // Alibaba/Oracle metadata, in CGNAT space
"100.64.0.1", // CGNAT lower bound
"100.127.255.254", // CGNAT upper bound
"192.0.0.1", // IETF protocol assignments
"198.19.0.1", // benchmarking range
"255.255.255.255", // broadcast
"localhost",
"localhost.", // trailing root dot
"LOCALHOST",
"svc.localhost",
"svc.localhost.",
"printer.local",
"printer.local.",
"::1",
"[::1]",
"::ffff:127.0.0.1", // IPv4-mapped loopback
"::ffff:169.254.169.254",
"::127.0.0.1", // IPv4-compatible loopback
"64:ff9b::7f00:1", // NAT64-embedded loopback
"fc00::1", // unique-local
"fe80::1", // link-local
"fec0::1", // deprecated site-local
] {
assert!(is_blocked_host(h), "{h} should be blocked");
}
}
#[test]
fn host_guard_allows_public_hosts() {
for h in [
"example.com",
"example.com.",
"did.example.org",
"localhost.example.com", // "localhost" only as the whole name or a suffix label
"8.8.8.8",
"99.64.0.1", // just below CGNAT
"100.63.0.1", // just below CGNAT
"100.128.0.1", // just above CGNAT
"2606:4700:4700::1111",
"64:ff9b::808:808", // NAT64-embedded public address
] {
assert!(!is_blocked_host(h), "{h} should be allowed");
}
}
}