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] = &[
175 "localhost", "LOCALHOST", "localhost.", "app.localhost",
176 "127.0.0.1", "127.1.2.3", "127.0.0.255", "[::1]",
177 ];
178 /// Hosts it must not, including the ones built to look local.
179 const REMOTE: &[&str] = &[
180 "evil.com", "example.org", "192.168.1.1", "10.0.0.1", "169.254.169.254",
181 "localhost.evil.com", "127.0.0.1.evil.com", "notlocalhost", "localhostx",
182 "2130706433", "0177.0.0.1", "0.0.0.0",
183 ];
184
185 proptest! {
186 #![proptest_config(ProptestConfig::with_cases(4000))]
187
188 /// The verdict depends on the HOST COMPONENT and nothing else.
189 ///
190 /// Every bypass this module has faced is a way to smuggle a local-looking string into some
191 /// other part of the URL — userinfo, path, query, fragment — and have a careless parser read
192 /// it as the host. Rather than enumerate those tricks one at a time, assemble URLs from
193 /// parts and assert the answer tracks the host alone, whatever surrounds it.
194 #[test]
195 fn only_the_host_component_decides(
196 scheme in prop::sample::select(vec!["", "http", "https", "tcp", "HTTP"]),
197 userinfo in prop::sample::select(vec!["", "user", "user:pass", "localhost", "127.0.0.1", "a@b"]),
198 local in any::<bool>(),
199 idx in 0usize..32,
200 port in prop::sample::select(vec!["", ":1", ":8000", ":65535"]),
201 tail in prop::sample::select(vec!["", "/", "/p", "/p?q=1", "?q=1", "#f", "/@localhost", "#@localhost", "?@localhost", "\\\\@localhost"]),
202 ) {
203 let pool = if local { LOCAL } else { REMOTE };
204 let host = pool[idx % pool.len()];
205 let mut s = String::new();
206 if !scheme.is_empty() {
207 s.push_str(&format!("{scheme}://"));
208 }
209 if !userinfo.is_empty() {
210 s.push_str(&format!("{userinfo}@"));
211 }
212 s.push_str(host);
213 s.push_str(port);
214 s.push_str(tail);
215 prop_assert_eq!(
216 is_loopback(&s),
217 local,
218 "host `{}` decides, but `{}` said otherwise", host, s,
219 );
220 }
221
222 /// Hostnames are case-insensitive, so case must never change the verdict. A missed
223 /// lowercase would make `LOCALHOST` deny (an annoyance) or, in a future spelling, make a
224 /// case variant slip past a check that only matched lowercase.
225 #[test]
226 fn case_never_changes_the_verdict(
227 local in any::<bool>(),
228 idx in 0usize..32,
229 port in prop::sample::select(vec!["", ":8000"]),
230 ) {
231 let pool = if local { LOCAL } else { REMOTE };
232 let host = format!("http://{}{}", pool[idx % pool.len()], port);
233 // Asserted against `local`, not merely against itself: a consistency-only claim is
234 // satisfied by an implementation that returns false for everything.
235 prop_assert_eq!(is_loopback(&host), local);
236 prop_assert_eq!(is_loopback(&host.to_uppercase()), local);
237 prop_assert_eq!(is_loopback(&host.to_lowercase()), local);
238 }
239
240 /// A recognized host stops being recognized the moment it becomes a LABEL of some other
241 /// domain. This is the `localhost.evil.com` class, stated for arbitrary suffixes rather
242 /// than the three the table happens to list.
243 #[test]
244 fn a_local_host_under_another_domain_is_remote(
245 idx in 0usize..32,
246 suffix in "[a-z][a-z0-9-]{0,20}\\.[a-z]{2,6}",
247 ) {
248 let host = LOCAL[idx % LOCAL.len()];
249 // A bracketed IPv6 cannot take a suffix; `[::1].evil.com` is malformed, not a spoof.
250 if host.starts_with('[') {
251 return Ok(());
252 }
253 let spoof = format!("http://{}.{}", host.trim_end_matches('.'), suffix);
254 prop_assert!(!is_loopback(&spoof), "expected remote: {}", spoof);
255 }
256
257 /// Never panics, whatever bytes arrive. `--endpoint-url` takes an attacker-influenced value
258 /// and a panic in the classifier is an availability bug in every harness hook that calls it.
259 #[test]
260 fn never_panics_on_any_input(s in ".*") {
261 let _ = is_loopback(&s);
262 }
263
264 /// Adding a valid port never changes the verdict — the host decides, the port is noise.
265 #[test]
266 fn a_port_never_changes_the_verdict(
267 local in any::<bool>(),
268 idx in 0usize..32,
269 port in 1u32..65535,
270 ) {
271 let pool = if local { LOCAL } else { REMOTE };
272 let host = pool[idx % pool.len()];
273 prop_assert_eq!(is_loopback(&format!("http://{host}")), local);
274 prop_assert_eq!(is_loopback(&format!("http://{host}:{port}")), local);
275 }
276 }
277
278
279 #[test]
280 fn recognizes_the_vetted_loopback_spellings() {
281 for v in [
282 "http://localhost",
283 "http://localhost:8000",
284 "http://localhost:8000/path?q=1",
285 "https://LOCALHOST:443",
286 "http://localhost.",
287 "http://localhost.:8000",
288 "http://myapp.localhost:3000",
289 "http://127.0.0.1",
290 "http://127.0.0.1:8000",
291 "http://127.1.2.3:9",
292 "http://127.0.0.255",
293 "http://[::1]",
294 "http://[::1]:8000",
295 "localhost",
296 "localhost:5432",
297 "127.0.0.1:5432",
298 "tcp://127.0.0.1:2375",
299 "http://user:pass@localhost:8000",
300 ] {
301 assert!(is_loopback(v), "expected loopback: {v}");
302 }
303 }
304
305 /// Each of these is a way to make our parse disagree with the tool's. A regression here is a
306 /// redirected authenticated request, not a cosmetic bug.
307 #[test]
308 fn rejects_hosts_that_only_look_local() {
309 for v in [
310 // userinfo carrying the local-looking name
311 "http://localhost@evil.com",
312 "http://localhost@evil.com/x",
313 "http://a@localhost@evil.com",
314 "http://127.0.0.1@evil.com",
315 // backslash is a path separator to WHATWG, so the host is evil.com
316 "http://evil.com\\@localhost",
317 "http://evil.com\\@127.0.0.1",
318 // the authority ends at the query/fragment
319 "http://evil.com#@localhost",
320 "http://evil.com?@localhost",
321 "http://evil.com/@localhost",
322 // prefix/suffix confusion
323 "http://localhost.evil.com",
324 "http://127.0.0.1.evil.com",
325 "http://notlocalhost",
326 "http://localhostx",
327 "http://evil-localhost.com",
328 // `.localhost` needs a real label
329 "http://.localhost",
330 // ambiguous or unrecognized address spellings — loopback in fact, denied on principle
331 "http://2130706433",
332 "http://0177.0.0.1",
333 "http://127.0.0.01",
334 "http://0x7f000001",
335 "http://127.1",
336 "http://127.0.0.256",
337 "http://127.0.0.1.1",
338 // encoding tricks have no decode step to exploit
339 "http://%6cocalhost",
340 "http://loc%61lhost",
341 "http://localhos\u{0074}.evil.com",
342 // not this machine
343 "http://192.168.1.1",
344 "http://10.0.0.1",
345 "http://169.254.169.254",
346 "https://evil.com",
347 // 0.0.0.0 means "every interface" when binding; as a target it is not a name for here
348 "http://0.0.0.0:8000",
349 // malformed
350 "http://",
351 "http://[::1",
352 "http://[::1]x",
353 "http://localhost:notaport",
354 "http://localhost..",
355 "",
356 ] {
357 assert!(!is_loopback(v), "expected NOT loopback: {v}");
358 }
359 }
360
361 #[test]
362 fn a_local_looking_label_never_survives_being_a_subdomain_of_something_else() {
363 // Property: appending any registrable suffix to a recognized host must un-recognize it.
364 for local in ["localhost", "127.0.0.1", "app.localhost"] {
365 assert!(is_loopback(local), "sanity: {local}");
366 for suffix in ["evil.com", "attacker.net", "co.uk"] {
367 let spoof = format!("http://{local}.{suffix}");
368 assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
369 }
370 }
371 }
372
373 #[test]
374 fn userinfo_never_decides_the_host() {
375 // Property: whatever precedes an `@`, the host is what follows the last one.
376 for user in ["localhost", "127.0.0.1", "[::1]", "a@localhost", "x"] {
377 let spoof = format!("http://{user}@evil.com/");
378 assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
379 let real = format!("http://{user}@localhost:8000/");
380 assert!(is_loopback(&real), "expected loopback: {real}");
381 }
382 }
383
384 #[test]
385 fn the_v4_recognizer_covers_the_whole_loopback_block_and_nothing_outside_it() {
386 for a in [0u16, 1, 9, 10, 126, 127, 128, 192, 255] {
387 for d in [0u16, 1, 127, 254, 255] {
388 let host = format!("{a}.0.0.{d}");
389 assert_eq!(
390 is_loopback(&host),
391 a == 127,
392 "127.0.0.0/8 membership decides {host}",
393 );
394 }
395 }
396 }
397
398 #[test]
399 fn never_panics_on_arbitrary_input() {
400 for v in [
401 "\u{0}", "://", "]", "[", "@", ":", "...", "http://:", "http://@",
402 "http://[]", "http://[]:", "\\\\", "a://b://c", &"a".repeat(1024),
403 "http://[::1]:99999999", "http://localhost:00000",
404 ] {
405 let _ = is_loopback(v);
406 }
407 }
408}