Skip to main content

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}