From f19561a0b422376348c91537e51377ab7325f4ee Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sun, 25 Oct 2026 21:05:19 +0100 Subject: [PATCH] Refuse bracketed and trailing-dot IP literals in normalize/2 The connect path drops a [...] pair and the resolver ignores a trailing dot, so %5B%3A%3A1%5D and 127.0.0.1. passed the IP-literal gate and still reached the address. Strip both before the gate. --- NEWS.md | 4 ++++ src/hackney_url.erl | 18 +++++++++++++++++- test/hackney_url_tests.erl | 29 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index f5fef9fe..4e3d5cfb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -27,6 +27,10 @@ Unreleased cap you get `{error, body_too_large}`. Set `max_body_size` (or the 1.x `max_body`) to change it, `infinity` to remove it, and stream larger downloads (#980). +- URLs that hide a loopback or private IP inside a percent-encoded + bracketed IPv6 address, or behind a trailing dot, are now refused like + other IP literals. The earlier fix for CVE-2026-47076 missed these two + spellings, so such a URL still reached the address it denotes (#987). ### Changed diff --git a/src/hackney_url.erl b/src/hackney_url.erl index bb3bc7c0..4e69292f 100644 --- a/src/hackney_url.erl +++ b/src/hackney_url.erl @@ -186,7 +186,7 @@ normalize(#hackney_url{}=Url, Fun) when is_function(Fun, 1) -> %% written with them normalises to the same literal. %% Reject the differential on the dot-canonicalised host. %% IDN / pct-encoded UTF-8 hosts still flow through. - case inet_parse:address(dot_canonical(DecodedBin)) of + case inet_parse:address(ip_candidate(dot_canonical(DecodedBin))) of {ok, _} -> error({invalid_url_host, Host0}); _ -> ok end, @@ -238,6 +238,22 @@ dot_canonical(Bin) when is_binary(Bin) -> _ -> binary_to_list(Bin) end. +%% @private Strip what the connect path ignores before it reads a host as an +%% address: one `[...]' pair (hackney_happy:parse_address/1 drops it) and one +%% trailing dot (the resolver reads it as the root label). Without this, +%% `%5B%3A%3A1%5D' and `127.0.0.1.' pass the IP gate yet still reach the +%% literal address (GHSA-5643). +ip_candidate("[" ++ Rest) -> + case lists:reverse(Rest) of + "]" ++ Inner -> ip_candidate(lists:reverse(Inner)); + _ -> "[" ++ Rest + end; +ip_candidate(Host) -> + case lists:reverse(Host) of + "." ++ Inner when Inner =/= [] -> lists:reverse(Inner); + _ -> Host + end. + fold_dot(16#3002) -> $.; fold_dot(16#FF0E) -> $.; fold_dot(16#FF61) -> $.; diff --git a/test/hackney_url_tests.erl b/test/hackney_url_tests.erl index a9b4365d..d13e623e 100644 --- a/test/hackney_url_tests.erl +++ b/test/hackney_url_tests.erl @@ -628,6 +628,35 @@ normalize_rejects_idna_dot_ip_host_test_() -> hackney_url:normalize(Url)) end} || Url <- Cases]. +%% GHSA-5643: the connect path drops a bracket pair and the resolver ignores a +%% trailing dot, so these spellings reached the literal address while passing +%% the IP gate. +normalize_rejects_bracketed_and_trailing_dot_ip_host_test_() -> + Cases = [ + <<"http://%5B%3A%3A1%5D/">>, %% [::1] + <<"http://%5B%3A%3Affff%3A127%2E0%2E0%2E1%5D/">>, %% [::ffff:127.0.0.1] + <<"http://%5B%3A%3Affff%3A7f00%3A1%5D/">>, %% hex-word form + <<"http://%5b%3A%3AFFFF%3A127.0.0.1%5d/">>, %% mixed case + <<"http://%31%32%37%2E%30%2E%30%2E%31%2E/">>, %% 127.0.0.1. + <<"http://127.0.0.1./">>, %% unencoded + <<"http://127", 16#3002/utf8, "0", 16#3002/utf8, "0", + 16#3002/utf8, "1", 16#3002/utf8, "/">> %% Unicode dots + trailing + ], + [{binary_to_list(Url), fun() -> + ?assertError({invalid_url_host, _}, + hackney_url:normalize(Url)) + end} || Url <- Cases]. + +%% Ordinary hosts keep normalising: a trailing-dot domain name is still a +%% domain name, and an unencoded IPv6 literal is a visible literal. +normalize_keeps_trailing_dot_names_and_ipv6_literals_test_() -> + [?_assertEqual("example.com.", + (hackney_url:normalize(<<"http://example.com./">>))#hackney_url.host), + ?_assertEqual("::1", + (hackney_url:normalize(<<"http://[::1]:8080/">>))#hackney_url.host), + ?_assertEqual("xn--bcher-kva.example", + (hackney_url:normalize(<<"http://b", 16#FC/utf8, "cher.example/">>))#hackney_url.host)]. + %% GHSA-9653: parse_url must not mint a fresh atom for every attacker-supplied %% scheme. binary_to_existing_atom keeps the atom table bounded; unknown %% schemes are returned as the lowercased binary instead.