fix(client): reject all obfuscated IPv4 host forms at parse time

Replace the narrow decimal/hex obfuscation check with a general
comparison: if the parsed host is an IPv4 literal and the raw input
host differs from the canonical dotted-quad form, the user supplied
an obfuscated variant (octal, short-form, mixed radix, leading
zeros, decimal integer, hex integer) that url::Url has already
normalized to 127.0.0.1. All such variants are rejected. Test now
covers decimal, hex, octal, two-/three-part short, mixed hex/
decimal, and leading-zero octets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-20 22:18:44 -04:00
parent 1214bebd14
commit 125a73aae3
No known key found for this signature in database
2 changed files with 21 additions and 14 deletions

View file

@ -176,7 +176,17 @@ mod tests {
#[test]
fn rejects_obfuscated_ipv4_literals_at_parse_time() {
for api_url in ["http://2130706433", "http://0x7f000001"] {
let cases = [
"http://2130706433", // decimal integer
"http://0x7f000001", // hex integer
"http://0177.0.0.1", // octal dotted
"http://127.1", // two-part short
"http://127.0.1", // three-part short
"http://0x7f.0.0.1", // mixed hex/decimal
"http://127.00.0.1", // leading-zero octet
"http://127.0.0.001", // leading-zero octet
];
for api_url in cases {
assert!(
ServerTarget::http_url(api_url).is_err(),
"{api_url} should not parse as a server target"

View file

@ -146,13 +146,16 @@ fn canonical_http_url(value: &str) -> Result<String> {
_ => bail!("server target must be an http(s) URL or absolute Unix socket path"),
};
if raw_url_host(normalized).is_some_and(is_obfuscated_ipv4_literal) {
bail!("server target must be an http(s) URL or absolute Unix socket path");
}
let Some(host) = url.host_str() else {
bail!("server target must be an http(s) URL or absolute Unix socket path");
};
if host.parse::<std::net::Ipv4Addr>().is_ok()
&& raw_url_host(normalized).is_some_and(|raw| raw != host)
{
bail!("server target must be an http(s) URL or absolute Unix socket path");
}
let host = host.to_ascii_lowercase();
let Some(port) = url.port_or_known_default() else {
bail!("server target must be an http(s) URL or absolute Unix socket path");
@ -171,8 +174,9 @@ fn trim_api_path_suffix(value: &str) -> &str {
}
/// Extract the host substring from `value` without going through
/// [`fabro_http::Url`]. `Url` normalizes decimal/hex IPv4 literals into dotted
/// form, which hides the original input from later inspection.
/// [`fabro_http::Url`]. `Url` normalizes IPv4 literals (decimal/hex/octal/short
/// form) into dotted quads, which hides the original input from later
/// inspection.
fn raw_url_host(value: &str) -> Option<&str> {
let (_, remainder) = value.split_once("://")?;
let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
@ -189,13 +193,6 @@ fn raw_url_host(value: &str) -> Option<&str> {
(!host.is_empty()).then_some(host)
}
fn is_obfuscated_ipv4_literal(host: &str) -> bool {
if host.starts_with("0x") || host.starts_with("0X") {
return true;
}
!host.contains('.') && !host.is_empty() && host.bytes().all(|b| b.is_ascii_digit())
}
fn lexical_normalize_absolute_path(path: &Path) -> Result<PathBuf> {
if !path.is_absolute() {
bail!("server target must be an http(s) URL or absolute Unix socket path");