const LOOPBACK_NAMES: &[&str] = &["localhost"];
pub fn is_loopback(value: &str) -> bool {
host_of(value).is_some_and(|h| is_loopback_host(&h))
}
fn host_of(value: &str) -> Option<String> {
if value.is_empty() || value.len() > 512 {
return None;
}
let after_scheme = match value.find("://") {
Some(i) => {
let scheme = &value[..i];
if scheme.is_empty()
|| !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
|| !scheme.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
{
return None;
}
&value[i + 3..]
}
None => value,
};
let authority = match after_scheme.find(['/', '\\', '?', '#']) {
Some(i) => &after_scheme[..i],
None => after_scheme,
};
let hostport = match authority.rfind('@') {
Some(i) => &authority[i + 1..],
None => authority,
};
if hostport.is_empty() {
return None;
}
let host = if let Some(rest) = hostport.strip_prefix('[') {
let (inner, after) = rest.split_once(']')?;
if !after.is_empty() && !after.strip_prefix(':').is_some_and(is_port) {
return None;
}
inner
} else {
match hostport.split_once(':') {
Some((h, port)) if is_port(port) => h,
Some(_) => return None,
None => hostport,
}
};
if host.is_empty() {
return None;
}
if !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':')) {
return None;
}
let host = host.to_ascii_lowercase();
let host = host.strip_suffix('.').unwrap_or(&host).to_string();
if host.is_empty() || host.ends_with('.') {
return None;
}
Some(host)
}
fn is_port(s: &str) -> bool {
!s.is_empty() && s.len() <= 5 && s.bytes().all(|b| b.is_ascii_digit())
}
fn is_loopback_host(host: &str) -> bool {
if LOOPBACK_NAMES.contains(&host) {
return true;
}
if let Some(label) = host.strip_suffix(".localhost")
&& !label.is_empty()
{
return true;
}
if host == "::1" || host == "0:0:0:0:0:0:0:1" {
return true;
}
is_loopback_v4(host)
}
fn is_loopback_v4(host: &str) -> bool {
let mut octets = host.split('.');
let mut parsed = [0u16; 4];
for slot in &mut parsed {
let Some(o) = octets.next() else { return false };
if o.is_empty() || o.len() > 3 || !o.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
if o.len() > 1 && o.starts_with('0') {
return false;
}
let Ok(v) = o.parse::<u16>() else { return false };
if v > 255 {
return false;
}
*slot = v;
}
octets.next().is_none() && parsed[0] == 127
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
const LOCAL: &[&str] = &[
"localhost", "LOCALHOST", "localhost.", "app.localhost",
"127.0.0.1", "127.1.2.3", "127.0.0.255", "[::1]",
];
const REMOTE: &[&str] = &[
"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", "notlocalhost", "localhostx",
"2130706433", "0177.0.0.1", "0.0.0.0",
];
proptest! {
#![proptest_config(ProptestConfig::with_cases(4000))]
#[test]
fn only_the_host_component_decides(
scheme in prop::sample::select(vec!["", "http", "https", "tcp", "HTTP"]),
userinfo in prop::sample::select(vec!["", "user", "user:pass", "localhost", "127.0.0.1", "a@b"]),
local in any::<bool>(),
idx in 0usize..32,
port in prop::sample::select(vec!["", ":1", ":8000", ":65535"]),
tail in prop::sample::select(vec!["", "/", "/p", "/p?q=1", "?q=1", "#f", "/@localhost", "#@localhost", "?@localhost", "\\\\@localhost"]),
) {
let pool = if local { LOCAL } else { REMOTE };
let host = pool[idx % pool.len()];
let mut s = String::new();
if !scheme.is_empty() {
s.push_str(&format!("{scheme}://"));
}
if !userinfo.is_empty() {
s.push_str(&format!("{userinfo}@"));
}
s.push_str(host);
s.push_str(port);
s.push_str(tail);
prop_assert_eq!(
is_loopback(&s),
local,
"host `{}` decides, but `{}` said otherwise", host, s,
);
}
#[test]
fn case_never_changes_the_verdict(
local in any::<bool>(),
idx in 0usize..32,
port in prop::sample::select(vec!["", ":8000"]),
) {
let pool = if local { LOCAL } else { REMOTE };
let host = format!("http://{}{}", pool[idx % pool.len()], port);
prop_assert_eq!(is_loopback(&host), local);
prop_assert_eq!(is_loopback(&host.to_uppercase()), local);
prop_assert_eq!(is_loopback(&host.to_lowercase()), local);
}
#[test]
fn a_local_host_under_another_domain_is_remote(
idx in 0usize..32,
suffix in "[a-z][a-z0-9-]{0,20}\\.[a-z]{2,6}",
) {
let host = LOCAL[idx % LOCAL.len()];
if host.starts_with('[') {
return Ok(());
}
let spoof = format!("http://{}.{}", host.trim_end_matches('.'), suffix);
prop_assert!(!is_loopback(&spoof), "expected remote: {}", spoof);
}
#[test]
fn never_panics_on_any_input(s in ".*") {
let _ = is_loopback(&s);
}
#[test]
fn a_port_never_changes_the_verdict(
local in any::<bool>(),
idx in 0usize..32,
port in 1u32..65535,
) {
let pool = if local { LOCAL } else { REMOTE };
let host = pool[idx % pool.len()];
prop_assert_eq!(is_loopback(&format!("http://{host}")), local);
prop_assert_eq!(is_loopback(&format!("http://{host}:{port}")), local);
}
}
#[test]
fn recognizes_the_vetted_loopback_spellings() {
for v in [
"http://localhost",
"http://localhost:8000",
"http://localhost:8000/path?q=1",
"https://LOCALHOST:443",
"http://localhost.",
"http://localhost.:8000",
"http://myapp.localhost:3000",
"http://127.0.0.1",
"http://127.0.0.1:8000",
"http://127.1.2.3:9",
"http://127.0.0.255",
"http://[::1]",
"http://[::1]:8000",
"localhost",
"localhost:5432",
"127.0.0.1:5432",
"tcp://127.0.0.1:2375",
"http://user:pass@localhost:8000",
] {
assert!(is_loopback(v), "expected loopback: {v}");
}
}
#[test]
fn rejects_hosts_that_only_look_local() {
for v in [
"http://localhost@evil.com",
"http://localhost@evil.com/x",
"http://a@localhost@evil.com",
"http://127.0.0.1@evil.com",
"http://evil.com\\@localhost",
"http://evil.com\\@127.0.0.1",
"http://evil.com#@localhost",
"http://evil.com?@localhost",
"http://evil.com/@localhost",
"http://localhost.evil.com",
"http://127.0.0.1.evil.com",
"http://notlocalhost",
"http://localhostx",
"http://evil-localhost.com",
"http://.localhost",
"http://2130706433",
"http://0177.0.0.1",
"http://127.0.0.01",
"http://0x7f000001",
"http://127.1",
"http://127.0.0.256",
"http://127.0.0.1.1",
"http://%6cocalhost",
"http://loc%61lhost",
"http://localhos\u{0074}.evil.com",
"http://192.168.1.1",
"http://10.0.0.1",
"http://169.254.169.254",
"https://evil.com",
"http://0.0.0.0:8000",
"http://",
"http://[::1",
"http://[::1]x",
"http://localhost:notaport",
"http://localhost..",
"",
] {
assert!(!is_loopback(v), "expected NOT loopback: {v}");
}
}
#[test]
fn a_local_looking_label_never_survives_being_a_subdomain_of_something_else() {
for local in ["localhost", "127.0.0.1", "app.localhost"] {
assert!(is_loopback(local), "sanity: {local}");
for suffix in ["evil.com", "attacker.net", "co.uk"] {
let spoof = format!("http://{local}.{suffix}");
assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
}
}
}
#[test]
fn userinfo_never_decides_the_host() {
for user in ["localhost", "127.0.0.1", "[::1]", "a@localhost", "x"] {
let spoof = format!("http://{user}@evil.com/");
assert!(!is_loopback(&spoof), "expected NOT loopback: {spoof}");
let real = format!("http://{user}@localhost:8000/");
assert!(is_loopback(&real), "expected loopback: {real}");
}
}
#[test]
fn the_v4_recognizer_covers_the_whole_loopback_block_and_nothing_outside_it() {
for a in [0u16, 1, 9, 10, 126, 127, 128, 192, 255] {
for d in [0u16, 1, 127, 254, 255] {
let host = format!("{a}.0.0.{d}");
assert_eq!(
is_loopback(&host),
a == 127,
"127.0.0.0/8 membership decides {host}",
);
}
}
}
#[test]
fn never_panics_on_arbitrary_input() {
for v in [
"\u{0}", "://", "]", "[", "@", ":", "...", "http://:", "http://@",
"http://[]", "http://[]:", "\\\\", "a://b://c", &"a".repeat(1024),
"http://[::1]:99999999", "http://localhost:00000",
] {
let _ = is_loopback(v);
}
}
}