VYPR
Unrated severityNVD Advisory· Published May 25, 2026

SSRF allowlist bypass via percent-encoded host in hackney

CVE-2026-47076

Description

Interpretation Conflict vulnerability in benoitc hackney allows Server Side Request Forgery. hackney_url:normalize/2 URL-decodes the host component after the URL has been parsed into a #hackney_url{} record. OTP's uri_string:parse/1 and inet:parse_address/1 do not decode percent-escapes in the host, so a URL such as http://%31%32%37%2E%30%2E%30%2E%31/ is seen by a caller's allowlist validator with host %31%32%37%2E%30%2E%30%2E%31 (not an IP address), which passes the allowlist check. hackney's normalizer then decodes the host to 127.0.0.1 and opens a TCP connection to loopback. Because hackney:request/5 always calls hackney_url:normalize/2 with no opt-out, every request that takes a binary or list URL is affected. The same technique reaches cloud instance metadata services (169.254.169.254), RFC1918 networks, and any admin interface listening on localhost.

This issue affects hackney: from 0.13.0 before 4.0.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Parser-differential SSRF in hackney ≤4.0.1: percent-encoded host bypasses allowlists, then decodes to internal IP by normalize/2.

Vulnerability

An interpretation conflict vulnerability (CWE-436) in the Erlang HTTP client hackney, versions 0.13.0 through 4.0.1, allows Server-Side Request Forgery (SSRF). The function hackney_url:normalize/2 URL-decodes the host component after the URL has been parsed into a #hackney_url{} record, but OTP's uri_string:parse/1 and inet:parse_address/1 do not decode percent-escapes in the host [1][2]. This creates a parser differential: a URL such as http://%31%32%37%2E%30%2E%30%2E%31/ presents an encoded host that is not recognized as an IP address by an allowlist validator, which therefore passes; the normalizer then decodes it to 127.0.0.1 and opens a TCP connection to loopback [3]. Because hackney:request/5 always calls hackney_url:normalize/2 with no opt-out, every request path accepting a binary or list URL is affected [2][3].

Exploitation

An attacker needs only the ability to supply a crafted URL to a hackney-based client that has an SSRF allowlist protecting internal resources. The attacker provides a URL with percent-encoded octets in the host component (e.g., %31%32%37%2E%30%2E%30%2E%31 for 127.0.0.1). The allowlist sees the raw encoded string and, using OTP's uri_string:parse/1 and inet:parse_address/1, does not recognize it as an IP address and accepts the URL [2][3]. Hackney's normalize/2 then decodes the host to the real IP literal and connects to that address, bypassing the allowlist [3]. No special network position or authentication is required if the client processes attacker-provided URLs.

Impact

On success, the attacker can perform Server-Side Request Forgery (SSRF) against internal IPs that the allowlist intended to block. This includes reaching loopback interfaces (127.0.0.1), cloud instance metadata services (169.254.169.254 for AWS/GCP/Azure), RFC1918 private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and any admin interface listening on localhost [1][2][3]. The compromised CIA outcome is information disclosure (access to metadata or internal services) and potential further exploitation, depending on the reachable endpoints.

Mitigation

The vulnerability is fixed in hackney version 4.0.1, released on 2026-05-25. The fix is a one-line check in normalize/2 that rejects hosts where percent-encoding decodes to an IP literal, preventing the parser differential [4]. Users should upgrade to hackney 4.0.1 or later. There is no known KEV listing. No workaround is available for affected versions other than upgrading, as the vulnerable code path is always active.

AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Benoitc/Hackneyinferred2 versions
    >=0.13.0,<4.0.1+ 1 more
    • (no CPE)range: >=0.13.0,<4.0.1
    • (no CPE)range: >=0.13.0 <4.0.1

Patches

1
452620a92ec1

fix(security): refuse pct-encoded host that decodes to an IP (GHSA-pj7v)

https://github.com/benoitc/hackneyBenoit ChesneauMay 20, 2026via body-scan
2 files changed · +26 0
  • src/hackney_url.erl+12 0 modified
    @@ -176,6 +176,18 @@ normalize(#hackney_url{}=Url, Fun) when is_function(Fun, 1) ->
                                      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.
    +                       %% IDN / pct-encoded UTF-8 hosts still flow through.
    +                       case inet_parse:address(Host1) of
    +                         {ok, _} -> error({invalid_url_host, Host0});
    +                         _ -> ok
    +                       end,
    +
                            %% encode domain if needed
                            Host2 = case Scheme of
                                      http_unix -> Host1;
    
  • test/hackney_url_tests.erl+14 0 modified
    @@ -593,6 +593,20 @@ unsupported_scheme_test_() ->
             ?assertEqual(Expected#hackney_url.scheme, R#hackney_url.scheme)
          end} || {V, Expected} <- Tests].
     
    +%% GHSA-pj7v: normalize/2 must refuse hosts that don't parse as an IP literal
    +%% but decode to one - the parser-differential that lets percent-encoded IPs
    +%% sneak past caller-side SSRF allowlists.
    +normalize_rejects_pct_encoded_ip_host_test_() ->
    +    Cases = [
    +        <<"http://%31%32%37%2E%30%2E%30%2E%31/admin">>,           %% 127.0.0.1
    +        <<"http://%31%36%39%2E%32%35%34%2E%31%36%39%2E%32%35%34/">>, %% 169.254.169.254
    +        <<"http://%31%30%2E%30%2E%30%2E%35/">>                    %% 10.0.0.5
    +    ],
    +    [{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.
    

Vulnerability mechanics

Root cause

"Parser-differential: hackney_url:normalize/2 URL-decodes the host after parsing, while the caller's SSRF allowlist (using uri_string:parse/1 and inet:parse_address/1) does not, allowing percent-encoded IP literals to bypass allowlist checks."

Attack vector

An attacker supplies a URL such as `http://%31%32%37%2E%30%2E%30%2E%31/` where the host is percent-encoded. The caller's SSRF allowlist uses OTP's `uri_string:parse/1` and `inet:parse_address/1`, neither of which decodes percent-escapes in the host, so the validator sees a non-IP token and permits the URL [ref_id=1]. Hackney's `normalize/2` then decodes the host to `127.0.0.1` and opens a TCP connection to loopback [ref_id=1]. The same technique reaches cloud IMDS endpoints (169.254.169.254), RFC1918 networks, and any localhost admin interface [ref_id=1]. No authentication is required; the attacker only needs to control a URL passed to `hackney:request/5` or any wrapper that accepts a binary or list URL [ref_id=1].

Affected code

The vulnerability resides in `src/hackney_url.erl` in the `normalize/2` function, specifically the branch that URL-decodes the host component after parsing [ref_id=1]. `hackney:request/5` at `src/hackney.erl:463` always calls `normalize/2` with no opt-out, so every request path accepting a binary or list URL is affected [ref_id=1].

What the fix does

The patch adds a check in `normalize/2` after the host is decoded: if `inet_parse:address(Host1)` succeeds (meaning the decoded host is an IP literal), the function raises `error({invalid_url_host, Host0})` [patch_id=2473699]. This rejects any non-IP host that decodes to an IP literal, closing the parser-differential that allowed percent-encoded IPs to bypass caller-side SSRF allowlists [patch_id=2473699]. Percent-encoded UTF-8 and IDN hosts still pass through because they do not decode to IP literals [patch_id=2473699].

Preconditions

  • inputThe application must accept attacker-supplied URLs and pass them to hackney:request/5 or any wrapper that accepts a binary or list URL.
  • configThe application must use an SSRF allowlist that relies on uri_string:parse/1 and inet:parse_address/1 (the canonical Erlang pattern) without percent-decoding the host first.
  • authNo authentication is required; the attacker only needs to control the URL input.

Reproduction

1. Validate the URL `http://%31%32%37%2E%30%2E%30%2E%31/` with the canonical Erlang SSRF allowlist: `uri_string:parse/1` returns host `%31%32%37%2E%30%2E%30%2E%31`, `inet:parse_address/1` returns `{error, einval}`, so the allowlist accepts it [ref_id=1]. 2. Pass the same URL to `hackney:get/1` [ref_id=1]. 3. Hackney's `normalize/2` decodes the host to `127.0.0.1` and connects to `127.0.0.1:80`; the internal service receives the request with `Host: 127.0.0.1` [ref_id=1].

Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.