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
38 changes: 27 additions & 11 deletions src/hackney_url.erl
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,19 @@ normalize(#hackney_url{}=Url, Fun) when is_function(Fun, 1) ->
{ok, {_, _, _, _, _, _, _, _}} ->
{Host0, Netloc0};
_ ->
Host1 = binary_to_list(
urldecode(unicode:characters_to_binary(Host0))
),

%% GHSA-pj7v: a non-IP host that decodes to an IP
%% literal (e.g. `%31%32%37%2E%30%2E%30%2E%31` ->
%% `127.0.0.1`) bypasses any caller-side SSRF
%% allowlist that ran inet:parse_address on the
%% pre-normalised host. Pct-encoding an IP literal
%% has no legitimate use; reject the differential.
DecodedBin = urldecode(unicode:characters_to_binary(Host0)),
Host1 = binary_to_list(DecodedBin),

%% A non-IP host that decodes to an IP literal (e.g.
%% `%31%32%37%2E%30%2E%30%2E%31` -> `127.0.0.1`) bypasses
%% any caller-side SSRF allowlist that ran
%% inet:parse_address on the pre-normalised host. IDNA
%% also folds the Unicode full-stop variants
%% (U+3002/U+FF0E/U+FF61) onto ASCII dots, so a host
%% 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(Host1) of
case inet_parse:address(dot_canonical(DecodedBin)) of
{ok, _} -> error({invalid_url_host, Host0});
_ -> ok
end,
Expand Down Expand Up @@ -226,6 +227,21 @@ idnconvert_hostname(Host) ->
idna:utf8_to_ascii(Host)
end.

%% @private Fold the Unicode full-stop variants that IDNA treats as label
%% separators onto ASCII '.', so an IP literal written with them is caught by
%% the inet_parse gate. Falls back to the raw bytes if the host is not valid
%% UTF-8 (which cannot be an IP literal anyway).
dot_canonical(Bin) when is_binary(Bin) ->
case unicode:characters_to_list(Bin) of
Cps when is_list(Cps) -> [fold_dot(C) || C <- Cps];
_ -> binary_to_list(Bin)
end.

fold_dot(16#3002) -> $.;
fold_dot(16#FF0E) -> $.;
fold_dot(16#FF61) -> $.;
fold_dot(C) -> C.

%% @doc True when Host is an IPv4/IPv6 literal. RFC 6066 forbids sending SNI
%% for IP literals. Hosts reach the TLS layer bracket-stripped, but strip a
%% stray bracket pair defensively.
Expand Down
21 changes: 21 additions & 0 deletions test/hackney_url_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,27 @@ normalize_rejects_pct_encoded_ip_host_test_() ->
hackney_url:normalize(Url))
end} || Url <- Cases].

%% normalize/2 must also refuse hosts that reach an IP literal only after IDNA
%% conversion: the Unicode full-stop variants split into ASCII-dot labels, so a
%% host like 127<U+3002>0<U+3002>0<U+3002>1 normalizes to 127.0.0.1 and would
%% otherwise bypass the IP gate. Codepoints are built explicitly so the case
%% does not depend on this source file's compile-time encoding.
normalize_rejects_idna_dot_ip_host_test_() ->
Ip = fun(Dot) ->
<<"http://127", Dot/binary, "0", Dot/binary, "0", Dot/binary, "1/x">>
end,
Cases = [
Ip(<<16#3002/utf8>>), %% IDEOGRAPHIC FULL STOP
Ip(<<16#FF0E/utf8>>), %% FULLWIDTH FULL STOP
Ip(<<16#FF61/utf8>>), %% HALFWIDTH IDEOGRAPHIC FULL STOP
%% pct-encoded U+3002 form
<<"http://127%E3%80%820%E3%80%820%E3%80%821/">>
],
[{binary_to_list(Url), fun() ->
?assertError({invalid_url_host, _},
hackney_url:normalize(Url))
end} || Url <- Cases].

%% 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