{"id":"CVE-2026-47076","title":"Hackney has SSRF allowlist bypass in hackney_url:normalize/2 via percent-encoded host","summary":"Hackney has SSRF allowlist bypass in hackney_url:normalize/2 via percent-encoded host","severity":"medium","cwe":["CWE-436","CWE-918"],"vendor":"hackney","product":"hackney","ecosystem":"erlang","affected":["hackney >= 0.13.0, < 4.0.1"],"patched":["hackney 4.0.1"],"published":"2026-06-26","updated":"2026-06-26","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-pj7v-xfvx-wmjq","references":[{"url":"https://github.com/benoitc/hackney/security/advisories/GHSA-pj7v-xfvx-wmjq"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-47076"},{"url":"https://github.com/benoitc/hackney/commit/452620a92ec1da2e6b4862a049a2a4f04b42068f"},{"url":"https://cna.erlef.org/cves/CVE-2026-47076.html"},{"url":"https://osv.dev/vulnerability/EEF-CVE-2026-47076"},{"url":"https://github.com/advisories/GHSA-pj7v-xfvx-wmjq"}],"tags":["ghsa","erlang"],"epss":0.00229,"epssPercentile":0.13942,"ingestedAt":"2026-06-29T13:24:35.111Z","slug":"CVE-2026-47076","body":"## Overview\n\n### Summary\n\n`hackney_url:normalize/2` URL-decodes the host component of a parsed URL, but the caller's SSRF allowlist runs before normalization using OTP's `uri_string:parse/1` and `inet:parse_address/1`, neither of which decodes percent-escapes in hostnames. A URL like `http://%31%32%37%2E%30%2E%30%2E%31/` presents an encoded, non-IP-looking host to the validator, which passes the allowlist check; hackney's normalizer then decodes it to `127.0.0.1` and connects to loopback. Because `hackney:request/5` always calls `normalize/2` with no opt-out, every request path that accepts a binary or list URL is affected. This is a parser-differential SSRF in the same class as CVE-2025-1211, but in a different function.\n\n### Details\n\nIn `src/hackney_url.erl` (lines 161–186), `normalize/2` checks whether the parsed host is already a dotted-quad or IPv6 literal via `inet_parse:address/1`. Percent-encoded forms like `%31%32%37%2E%30%2E%30%2E%31` fail that check and fall into the catch-all branch, where `urldecode/1` decodes the host before passing it to IDNA conversion:\n\n```erlang\nHost1 = binary_to_list(\n           urldecode(unicode:characters_to_binary(Host0))\n         ),\n```\n\nThe decoded host (`\"127.0.0.1\"`) replaces the original in the returned `#hackney_url{}` record. `hackney:request/5` at `src/hackney.erl:463` always calls `normalize/2`, so the decoded host is what `do_dispatch/1` and `add_host_header/2` ultimately use. The on-wire `Host:` header and the TCP connect target both reflect the decoded value.\n\nThe same payload pattern reaches the AWS/GCP/Azure IMDS (`169.254.169.254`), RFC1918 ranges, and any `localhost` admin endpoint. The 1.21.0 patch for CVE-2025-1211 fixed a separate differential in `parse_url/1` and did not touch `normalize/2`.\n\n### PoC\n\n1. Validate the URL 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.\n2. Pass the same URL to `hackney:get/1`.\n3. 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`.\n\n### Impact\n\nUnauthenticated SSRF bypassing the canonical Erlang allowlist pattern. Affects hackney 0.13.0 through 4.0.0 for any application that accepts attacker-supplied URLs. Targets include cloud IMDS endpoints, `localhost` admin interfaces, and RFC1918 backends. CVSS v4.0: **6.9 (MEDIUM)**.\n\n## Resources\n\n* Introduction commit: https://github.com/benoitc/hackney/commit/4d725507588942fd00efca15b86da3273656510a\n* Patch commit: https://github.com/benoitc/hackney/commit/452620a92ec1da2e6b4862a049a2a4f04b42068f\n\n## Affected packages\n\n- `hackney >= 0.13.0, < 4.0.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `hackney 4.0.1`","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}