safe_chains/netloc.rs
1//! Recognizing a network location that never leaves the machine.
2//!
3//! The behavioral taxonomy has always had the `loopback` rung on `network.direction` (v1.4 §2.5),
4//! and `reader` admits up to it — but nothing derived it from an actual host, so every endpoint
5//! read as remote. That forced an all-or-nothing choice on flags like `aws --endpoint-url`: admit
6//! it and an authenticated call can be redirected to an attacker's host, or withhold it and deny
7//! the developer running DynamoDB Local.
8//!
9//! This module decides that one question — does this value name THIS machine? — and it is the only
10//! place that decides it.
11//!
12//! # Why it is written as a positive recognizer
13//!
14//! Being wrong in one direction is an inconvenience and in the other a redirected authenticated
15//! request, so this is an allowlist over a tiny vetted set of spellings, and everything it does not
16//! positively recognize is remote. `http://2130706433/` really is loopback and this returns `false`
17//! for it; that is the intended behavior, not a gap to close. Adding a spelling is cheap; missing a
18//! bypass is not.
19//!
20//! # The bypasses it is built to survive
21//!
22//! A host string is attacker-controlled, and the attack is always to make our parse disagree with
23//! the tool's. The cases that drive this implementation:
24//!
25//! - `http://localhost@evil.com/` — the authority's host is `evil.com`; `localhost` is userinfo.
26//! - `http://evil.com\@localhost/` — WHATWG treats `\` as a path separator, so the host is
27//! `evil.com`; splitting on `@` first would read it as `localhost`.
28//! - `http://evil.com#@localhost` and `?@localhost` — the fragment/query start ends the authority.
29//! - `http://localhost.evil.com/` — a prefix, not the host.
30//! - `http://127.0.0.1.evil.com/` — likewise.
31//! - `http://0177.0.0.1/` — leading zeros are octal to some resolvers and decimal to others; an
32//! ambiguous spelling is not recognized at all.
33//! - Percent-encoding and non-ASCII (`%6c`, IDN homographs) — the host charset is enforced after
34//! extraction, so no decoding step exists to disagree about.
35
36/// The vetted loopback names. Anything not here, or not an address form below, is remote.
37const LOOPBACK_NAMES: &[&str] = &["localhost"];
38
39/// Whether `value` names a network location on this machine.
40///
41/// Accepts a URL (`http://localhost:8000/path`) or a bare authority (`127.0.0.1:5432`). Recognized:
42/// `localhost` and any `*.localhost` subdomain (RFC 6761 §6.3 reserves the TLD to loopback),
43/// `127.0.0.0/8` in dotted-quad form (RFC 1122 §3.2.1.3), and the IPv6 loopback `::1`.
44pub fn is_loopback(value: &str) -> bool {
45 host_of(value).is_some_and(|h| is_loopback_host(&h))
46}
47
48/// The host component, lowercased with a trailing root dot removed; `None` when the value cannot be
49/// parsed into a host we are confident about. Every `None` is a deny, so ambiguity resolves here.
50fn host_of(value: &str) -> Option<String> {
51 if value.is_empty() || value.len() > 512 {
52 return None;
53 }
54 // Strip the scheme. A value may also arrive as a bare authority (`localhost:8000`), so the
55 // absence of `://` is not itself disqualifying — but a lone `:` then has to be a port, which
56 // the host/port split below enforces.
57 let after_scheme = match value.find("://") {
58 Some(i) => {
59 let scheme = &value[..i];
60 if scheme.is_empty()
61 || !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
62 || !scheme.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
63 {
64 return None;
65 }
66 &value[i + 3..]
67 }
68 None => value,
69 };
70
71 // The authority ends at the first delimiter. `\` is included because WHATWG URL parsing treats
72 // it as `/`, so `evil.com\@localhost` has host `evil.com` — cutting here is what keeps the `@`
73 // split below from reading the tail as the host.
74 let authority = match after_scheme.find(['/', '\\', '?', '#']) {
75 Some(i) => &after_scheme[..i],
76 None => after_scheme,
77 };
78
79 // Userinfo is everything before the LAST `@` (browsers and curl agree; `a@b@host` has host
80 // `host`). This is the `localhost@evil.com` case.
81 let hostport = match authority.rfind('@') {
82 Some(i) => &authority[i + 1..],
83 None => authority,
84 };
85 if hostport.is_empty() {
86 return None;
87 }
88
89 let host = if let Some(rest) = hostport.strip_prefix('[') {
90 // Bracketed IPv6. Anything after the closing bracket must be a port and nothing else.
91 let (inner, after) = rest.split_once(']')?;
92 if !after.is_empty() && !after.strip_prefix(':').is_some_and(is_port) {
93 return None;
94 }
95 inner
96 } else {
97 match hostport.split_once(':') {
98 // A bare IPv6 without brackets is ambiguous against host:port, so a non-numeric tail is
99 // not something to guess at.
100 Some((h, port)) if is_port(port) => h,
101 Some(_) => return None,
102 None => hostport,
103 }
104 };
105 if host.is_empty() {
106 return None;
107 }
108
109 // Enforced AFTER extraction so no decoding step exists for us and the tool to disagree about:
110 // a percent-encoded or non-ASCII host is simply not recognized.
111 if !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':')) {
112 return None;
113 }
114
115 let host = host.to_ascii_lowercase();
116 // One trailing root dot is the FQDN spelling of the same name; more than one is malformed.
117 let host = host.strip_suffix('.').unwrap_or(&host).to_string();
118 if host.is_empty() || host.ends_with('.') {
119 return None;
120 }
121 Some(host)
122}
123
124fn is_port(s: &str) -> bool {
125 !s.is_empty() && s.len() <= 5 && s.bytes().all(|b| b.is_ascii_digit())
126}
127
128fn is_loopback_host(host: &str) -> bool {
129 if LOOPBACK_NAMES.contains(&host) {
130 return true;
131 }
132 // RFC 6761 §6.3: names under the `localhost` TLD resolve to loopback. The label before it must
133 // be non-empty, so a bare `.localhost` does not qualify.
134 if let Some(label) = host.strip_suffix(".localhost")
135 && !label.is_empty()
136 {
137 return true;
138 }
139 if host == "::1" || host == "0:0:0:0:0:0:0:1" {
140 return true;
141 }
142 is_loopback_v4(host)
143}
144
145/// Dotted-quad inside `127.0.0.0/8`. Requires all four octets, plain decimal, no leading zeros —
146/// a leading zero is octal to `inet_aton` and decimal to other parsers, and a spelling whose value
147/// depends on who reads it is not one to recognize.
148fn is_loopback_v4(host: &str) -> bool {
149 let mut octets = host.split('.');
150 let mut parsed = [0u16; 4];
151 for slot in &mut parsed {
152 let Some(o) = octets.next() else { return false };
153 if o.is_empty() || o.len() > 3 || !o.bytes().all(|b| b.is_ascii_digit()) {
154 return false;
155 }
156 if o.len() > 1 && o.starts_with('0') {
157 return false;
158 }
159 let Ok(v) = o.parse::<u16>() else { return false };
160 if v > 255 {
161 return false;
162 }
163 *slot = v;
164 }
165 octets.next().is_none() && parsed[0] == 127
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use proptest::prelude::*;
172
173 /// Hosts this module is meant to recognize, in the spellings a developer actually types.
174 const LOCAL: &[&str] = &["localhost", "LOCALHOST", "localhost.", "app.localhost", "127.0.0.1", "127.1.2.3", "127.0.0.255", "[::1]"];
175 /// Hosts it must not, including the ones built to look local.
176 const REMOTE: &[&str] = &[
177 "evil.com", "example.org", "192.168.1.1", "10.0.0.1", "169.254.169.254", "localhost.evil.com", "127.0.0.1.evil.com",
178 "notlocalhost", "localhostx", "2130706433", "0177.0.0.1", "0.0.0.0",
179 ];
180
181 proptest! {
182 #![proptest_config(ProptestConfig::with_cases(4000))]
183
184 /// The verdict depends on the HOST COMPONENT and nothing else.
185 ///
186 /// Every bypass this module has faced is a way to smuggle a local-looking string into some
187 /// other part of the URL — userinfo, path, query, fragment — and have a careless parser read
188 /// it as the host. Rather than enumerate those tricks one at a time, assemble URLs from
189 /// parts and assert the answer tracks the host alone, whatever surrounds it.
190 #[test]
191 fn only_the_host_component_decides(
192 scheme in prop::sample::select(vec!["", "http", "https", "tcp", "HTTP"]),
193 userinfo in prop::sample::select(vec!["", "user", "user:pass", "localhost", "127.0.0.1", "a@b"]),
194 local in any::<bool>(),
195 idx in 0usize..32,
196 port in prop::sample::select(vec!["", ":1", ":8000", ":65535"]),
197 tail in prop::sample::select(vec!["", "/", "/p", "/p?q=1", "?q=1", "#f", "/@localhost", "#@localhost", "?@localhost", "\\\\@localhost"]),
198 ) {
199 let pool = if local { LOCAL } else { REMOTE };
200 let host = pool[idx % pool.len()];
201 let mut s = String::new();
202 if !scheme.is_empty() {
203 s.push_str(&format!("{scheme}://"));
204 }
205 if !userinfo.is_empty() {
206 s.push_str(&format!("{userinfo}@"));
207 }
208 s.push_str(host);
209 s.push_str(port);
210 s.push_str(tail);
211 prop_assert_eq!(
212 is_loopback(&s),
213 local,
214 "host `{}` decides, but `{}` said otherwise", host, s,
215 );
216 }
217
218 /// Hostnames are case-insensitive, so case must never change the verdict. A missed
219 /// lowercase would make `LOCALHOST` deny (an annoyance) or, in a future spelling, make a
220 /// case variant slip past a check that only matched lowercase.
221 #[test]
222 fn case_never_changes_the_verdict(
223 local in any::<bool>(),
224 idx in 0usize..32,
225 port in prop::sample::select(vec!["", ":8000"]),
226 ) {
227 let pool = if local { LOCAL } else { REMOTE };
228 let host = format!("http://{}{}", pool[idx % pool.len()], port);
229 // Asserted against `local`, not merely against itself: a consistency-only claim is
230 // satisfied by an implementation that returns false for everything.
231 prop_assert_eq!(is_loopback(&host), local);
232 prop_assert_eq!(is_loopback(&host.to_uppercase()), local);
233 prop_assert_eq!(is_loopback(&host.to_lowercase()), local);
234 }
235
236 /// A recognized host stops being recognized the moment it becomes a LABEL of some other
237 /// domain. This is the `localhost.evil.com` class, stated for arbitrary suffixes rather
238 /// than the three the table happens to list.
239 #[test]
240 fn a_local_host_under_another_domain_is_remote(
241 idx in 0usize..32,
242 suffix in "[a-z][a-z0-9-]{0,20}\\.[a-z]{2,6}",
243 ) {
244 let host = LOCAL[idx % LOCAL.len()];
245 // A bracketed IPv6 cannot take a suffix; `[::1].evil.com` is malformed, not a spoof.
246 if host.starts_with('[') {
247 return Ok(());
248 }
249 let spoof = format!("http://{}.{}", host.trim_end_matches('.'), suffix);
250 prop_assert!(!is_loopback(&spoof), "expected remote: {}", spoof);
251 }
252
253 /// Never panics, whatever bytes arrive. `--endpoint-url` takes an attacker-influenced value
254 /// and a panic in the classifier is an availability bug in every harness hook that calls it.
255 #[test]
256 fn never_panics_on_any_input(s in ".*") {
257 let _ = is_loopback(&s);
258 }
259
260 /// Adding a valid port never changes the verdict — the host decides, the port is noise.
261 #[test]
262 fn a_port_never_changes_the_verdict(
263 local in any::<bool>(),
264 idx in 0usize..32,
265 port in 1u32..65535,
266 ) {
267 let pool = if local { LOCAL } else { REMOTE };
268 let host = pool[idx % pool.len()];
269 prop_assert_eq!(is_loopback(&format!("http://{host}")), local);
270 prop_assert_eq!(is_loopback(&format!("http://{host}:{port}")), local);
271 }
272 }
273
274 #[test]
275 fn recognizes_the_vetted_loopback_spellings() {
276 for v in [
277 "http://localhost", "http://localhost:8000", "http://localhost:8000/path?q=1", "https://LOCALHOST:443", "http://localhost.",
278 "http://localhost.:8000", "http://myapp.localhost:3000", "http://127.0.0.1", "http://127.0.0.1:8000", "http://127.1.2.3:9",
279 "http://127.0.0.255", "http://[::1]", "http://[::1]:8000", "localhost", "localhost:5432", "127.0.0.1:5432",
280 "tcp://127.0.0.1:2375", "http://user:pass@localhost:8000",
281 ] {
282 assert!(is_loopback(v), "expected loopback: {v}");
283 }
284 }
285
286 /// Each of these is a way to make our parse disagree with the tool's. A regression here is a
287 /// redirected authenticated request, not a cosmetic bug.
288 #[test]
289 fn rejects_hosts_that_only_look_local() {
290 let groups: &[&[&str]] = &[
291 // userinfo carrying the local-looking name
292 &["http://localhost@evil.com", "http://localhost@evil.com/x", "http://a@localhost@evil.com", "http://127.0.0.1@evil.com"],
293 // backslash is a path separator to WHATWG, so the host is evil.com
294 &["http://evil.com\\@localhost", "http://evil.com\\@127.0.0.1"],
295 // the authority ends at the query/fragment
296 &["http://evil.com#@localhost", "http://evil.com?@localhost", "http://evil.com/@localhost"],
297 // prefix/suffix confusion
298 &[
299 "http://localhost.evil.com", "http://127.0.0.1.evil.com", "http://notlocalhost", "http://localhostx",
300 "http://evil-localhost.com",
301 ],
302 // `.localhost` needs a real label
303 &["http://.localhost"],
304 // ambiguous or unrecognized address spellings — loopback in fact, denied on principle
305 &[
306 "http://2130706433", "http://0177.0.0.1", "http://127.0.0.01", "http://0x7f000001", "http://127.1", "http://127.0.0.256",
307 "http://127.0.0.1.1",
308 ],
309 // encoding tricks have no decode step to exploit
310 &["http://%6cocalhost", "http://loc%61lhost", "http://localhos\u{0074}.evil.com"],
311 // not this machine
312 &["http://192.168.1.1", "http://10.0.0.1", "http://169.254.169.254", "https://evil.com"],
313 // 0.0.0.0 means "every interface" when binding; as a target it is not a name for here
314 &["http://0.0.0.0:8000"],
315 // malformed
316 &["http://", "http://[::1", "http://[::1]x", "http://localhost:notaport", "http://localhost..", ""],
317 ];
318 for v in groups.iter().copied().flatten() {
319 assert!(!is_loopback(v), "expected NOT loopback: {v}");
320 }
321 }
322
323 #[test]
324 fn a_local_looking_label_never_survives_being_a_subdomain_of_something_else() {
325 // Property: appending any registrable suffix to a recognized host must un-recognize it.
326 for local in ["localhost", "127.0.0.1", "app.localhost"] {
327 assert!(is_loopback(local), "sanity: {local}");
328 for suffix in ["evil.com", "attacker.net", "co.uk"] {
329 let spoof = format!("http://{local}.{suffix}");
330 assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
331 }
332 }
333 }
334
335 #[test]
336 fn userinfo_never_decides_the_host() {
337 // Property: whatever precedes an `@`, the host is what follows the last one.
338 for user in ["localhost", "127.0.0.1", "[::1]", "a@localhost", "x"] {
339 let spoof = format!("http://{user}@evil.com/");
340 assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
341 let real = format!("http://{user}@localhost:8000/");
342 assert!(is_loopback(&real), "expected loopback: {real}");
343 }
344 }
345
346 #[test]
347 fn the_v4_recognizer_covers_the_whole_loopback_block_and_nothing_outside_it() {
348 for a in [0u16, 1, 9, 10, 126, 127, 128, 192, 255] {
349 for d in [0u16, 1, 127, 254, 255] {
350 let host = format!("{a}.0.0.{d}");
351 assert_eq!(is_loopback(&host), a == 127, "127.0.0.0/8 membership decides {host}",);
352 }
353 }
354 }
355
356 #[test]
357 fn never_panics_on_arbitrary_input() {
358 for v in [
359 "\u{0}",
360 "://",
361 "]",
362 "[",
363 "@",
364 ":",
365 "...",
366 "http://:",
367 "http://@",
368 "http://[]",
369 "http://[]:",
370 "\\\\",
371 "a://b://c",
372 &"a".repeat(1024),
373 "http://[::1]:99999999",
374 "http://localhost:00000",
375 ] {
376 let _ = is_loopback(v);
377 }
378 }
379}