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] = &["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}