diff --git a/src/hackney_url.erl b/src/hackney_url.erl index efbb4ee2..8e8f3774 100644 --- a/src/hackney_url.erl +++ b/src/hackney_url.erl @@ -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, @@ -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. diff --git a/test/hackney_url_tests.erl b/test/hackney_url_tests.erl index 38235c66..35c7c7ad 100644 --- a/test/hackney_url_tests.erl +++ b/test/hackney_url_tests.erl @@ -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 127001 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.