Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion src/hackney_url.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) -> $.;
Expand Down
29 changes: 29 additions & 0 deletions test/hackney_url_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading