use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use http::{Response, header::HOST};
use pingora_http::RequestHeader;
use praxis_core::connectivity::normalize_mapped_ipv4;
use tracing::debug;
use crate::http::pingora::json::json_response;
pub(crate) fn reject_non_loopback_host(req: &RequestHeader) -> Option<Response<Vec<u8>>> {
let header_hosts = req.headers.get_all(HOST).iter().map(|value| value.to_str().ok());
let uri_host = req.uri.authority().map(|authority| Some(authority.as_str()));
if header_hosts
.chain(uri_host)
.all(|host| host.is_some_and(is_loopback_host))
{
return None;
}
debug!(host = ?req.headers.get(HOST), path = %req.uri.path(), "admin request rejected: non-loopback Host");
Some(json_response(421, br#"{"error":"misdirected request"}"#))
}
pub(crate) fn is_loopback_host(host: &str) -> bool {
if let Ok(ip) = host.parse::<IpAddr>() {
return is_loopback_ip(ip);
}
if let Some(bracketed) = host.strip_prefix('[') {
return bracketed.split_once(']').is_some_and(|(literal, rest)| {
(rest.is_empty() || rest.strip_prefix(':').is_some_and(is_port))
&& literal
.parse::<Ipv6Addr>()
.is_ok_and(|addr| is_loopback_ip(IpAddr::V6(addr)))
});
}
host.rsplit_once(':')
.map_or(Some(host), |(name, port)| is_port(port).then_some(name))
.is_some_and(|name| name.parse::<Ipv4Addr>().is_ok_and(|addr| addr.is_loopback()) || is_localhost(name))
}
fn is_loopback_ip(ip: IpAddr) -> bool {
normalize_mapped_ipv4(ip).is_loopback()
}
fn is_localhost(name: &str) -> bool {
name.strip_suffix('.').unwrap_or(name).eq_ignore_ascii_case("localhost")
}
fn is_port(port: &str) -> bool {
port.bytes().all(|byte| byte.is_ascii_digit())
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")]
mod tests {
use http::HeaderValue;
use super::*;
#[test]
fn accepts_ipv4_loopback_with_and_without_port() {
for host in ["127.0.0.1", "127.0.0.1:9901", "127.1.2.3:80", "127.0.0.1:"] {
assert!(is_loopback_host(host), "{host} names IPv4 loopback");
}
}
#[test]
fn accepts_ipv6_loopback_bracketed_and_bare() {
for host in [
"[::1]",
"[::1]:9901",
"::1",
"[0:0:0:0:0:0:0:1]:80",
"[::ffff:127.0.0.1]:9901",
] {
assert!(is_loopback_host(host), "{host} names IPv6 loopback");
}
}
#[test]
fn accepts_localhost_in_any_case_with_trailing_dot() {
for host in [
"localhost",
"LOCALHOST",
"LocalHost:9901",
"localhost.",
"localhost.:9901",
] {
assert!(is_loopback_host(host), "{host} names localhost");
}
}
#[test]
fn rejects_dns_names() {
for host in [
"attacker.example",
"attacker.example:9901",
"localhost.attacker.example",
"127.0.0.1.attacker.example",
"foo.localhost",
"localhost..",
] {
assert!(!is_loopback_host(host), "{host} is a DNS name and must be rejected");
}
}
#[test]
fn rejects_non_loopback_ip_literals() {
for host in [
"0.0.0.0:9901",
"10.0.0.1",
"[::]:9901",
"[2001:db8::1]",
"::1:80",
"[::ffff:10.0.0.1]",
] {
assert!(!is_loopback_host(host), "{host} is not loopback");
}
}
#[test]
fn rejects_empty_and_malformed_hosts() {
for host in [
"",
":9901",
"[::1",
"[::1]x",
"[::1]:80x",
"[127.0.0.1]",
"127.0.0.1:80:80",
"127.0.0.1:+80",
"127.1",
"user@127.0.0.1",
"127.0.0.1 ",
"localhost:port",
] {
assert!(!is_loopback_host(host), "{host:?} is malformed and must be rejected");
}
}
#[test]
fn guard_allows_loopback_host_header() {
let req = request_with_hosts(b"/api/stats", &[b"127.0.0.1:9901"]);
assert!(reject_non_loopback_host(&req).is_none(), "loopback Host must pass");
}
#[test]
fn guard_allows_missing_host() {
let req = request_with_hosts(b"/api/stats", &[]);
assert!(
reject_non_loopback_host(&req).is_none(),
"a request without Host must pass"
);
}
#[test]
fn guard_rejects_rebound_host_with_421_json() {
let req = request_with_hosts(b"/api/kv/store/key", &[b"attacker.example:9901"]);
let resp = reject_non_loopback_host(&req).unwrap();
assert_eq!(resp.status().as_u16(), 421, "rebound Host must be misdirected");
assert_eq!(
resp.body(),
br#"{"error":"misdirected request"}"#,
"rejection must use the JSON error style"
);
assert_eq!(
resp.headers()["Content-Type"],
"application/json",
"rejection must be JSON"
);
}
#[test]
fn guard_rejects_empty_host() {
let req = request_with_hosts(b"/healthy", &[b""]);
assert!(
reject_non_loopback_host(&req).is_some(),
"an empty Host must be rejected"
);
}
#[test]
fn guard_rejects_non_utf8_host() {
let req = request_with_hosts(b"/healthy", &[b"local\xffhost"]);
assert!(
reject_non_loopback_host(&req).is_some(),
"a non-UTF-8 Host must be rejected"
);
}
#[test]
fn guard_rejects_any_non_loopback_host_among_duplicates() {
let req = request_with_hosts(b"/healthy", &[b"localhost", b"attacker.example"]);
assert!(
reject_non_loopback_host(&req).is_some(),
"every Host header must name loopback"
);
}
#[test]
fn guard_rejects_non_loopback_request_authority() {
let mut req = request_with_hosts(b"/api/stats", &[b"localhost"]);
req.set_uri(http::Uri::from_static("http://attacker.example/api/stats"));
assert!(
reject_non_loopback_host(&req).is_some(),
"a request authority (HTTP/2 :authority) must also name loopback"
);
}
#[test]
fn guard_allows_loopback_absolute_form_authority_without_host() {
let req = request_with_hosts(b"http://127.0.0.1:9901/api/stats", &[]);
assert!(
reject_non_loopback_host(&req).is_none(),
"a loopback absolute-form authority must pass"
);
}
fn request_with_hosts(path: &[u8], hosts: &[&[u8]]) -> RequestHeader {
let mut req = RequestHeader::build("GET", path, None).unwrap();
for host in hosts {
req.append_header(HOST, HeaderValue::from_bytes(host).unwrap()).unwrap();
}
req
}
}