Skip to content

Validate every resolved address and bind it to the request (CVE-2026-77866, CVE-2026-77972) - #16

Open
DiedeClaessens wants to merge 2 commits into
slab:mainfrom
starfish-codes:main
Open

DiedeClaessens wants to merge 2 commits into
slab:mainfrom
starfish-codes:main

Conversation

@DiedeClaessens

@DiedeClaessens DiedeClaessens commented Sep 22, 2026 •

Copy link
Copy Markdown

Fixes CVE-2026-77866 and CVE-2026-77972, which affect every release.

The holes

Only one address was judged. validate/2 took the first IPv4 address of a host and matched it against the reserved ranges and the blocklist; everything else matched nothing. So a host rejected in its IPv4 form passed written as an IPv6 address, IPv6 entries in a blocklist never matched, and a host that resolved to no A record was accepted whatever it pointed at.

The verdict was not bound to a request. validate/2 returned :ok and the caller handed the hostname to an HTTP client, which resolved it a second time. A name under an attacker's control answers with a public address for the check and an internal one for the request. The same window opens on its own whenever a name legitimately resolves to different addresses across lookups.

The bundled clients followed redirects. SafeURL.HTTPoison.get/3 passed :follow_redirect to hackney, which then requested whatever destination the server named, unvalidated.

What this does

  • The host is resolved once, and every address it has, in both families, has to pass. IPv4-mapped (::ffff:10.0.0.1) and IPv4-compatible (::10.0.0.1) addresses are checked as the IPv4 address they carry, and the reserved list now covers the IPv6 blocks IANA marks as not globally reachable.
  • A host that resolves to nothing fails with the new :unresolved_host instead of being let through.
  • :dns_module defaults to the new SafeURL.DNS, which looks up A and AAAA records and bounds each query (DNS.resolve/4 waits forever by default). The resolve/1 callback docs now say a resolver must return every address of every family.
  • New SafeURL.pin/2 returns the URL with the validated address in place of the host, plus hostname, address, scheme and port, so a caller connects to the address that passed while the name still governs the Host header, SNI and certificate verification. validate/2 and allowed?/2 are unchanged in behaviour and now sit on top of it.
  • SafeURL.HTTPoison.get/3 pins the request and rebuilds hackney's TLS options from the hostname, since supplying :ssl_options replaces hackney's defaults rather than adding to them. A caller who sets :ssl_options or :insecure keeps their own setup. Validation options move under :safeurl.
  • SafeURL.TeslaMiddleware pins any request without TLS, and https on Tesla.Adapter.Hackney. With another adapter an https request is validated but not pinned, because keeping certificate verification pointed at the hostname is spelled differently by each adapter; the moduledoc and the README say so.
  • Both clients raise on :follow_redirect, given directly or through the adapter options. With Tesla, placing the middleware after Tesla.Middleware.FollowRedirects validates every hop, and there is a test for that.

Breaking changes

guides/migrating_to_1.1.md lists them: the new :unresolved_host error, the widened reserved list (192.0.0.0/29 becomes IANA's 192.0.0.0/24), the new default resolver, and :follow_redirect raising. Everything it now rejects was one of the holes. The version stays on the 1.x line so {:safeurl, "~> 1.0"} picks the fix up.

Notes for review

  • hackney joins the optional deps with a ~> 1.16 floor: SafeURL.Client.hackney_ssl_options/1 calls :hackney_ssl.check_hostname_opts/1, which is exported but undocumented, and does not exist before 1.16.0. Without the floor, a Tesla user on hackney ~> 1.6 would hit UndefinedFunctionError at request time. Happy to inline hackney's defaults instead if you would rather not lean on that function.
  • guides is added to the package files, otherwise the migration guides are in docs: extras but not in the published tarball.
  • The CI workflow moves to ubuntu-22.04 and the v4 actions: ubuntu-20.04 is retired and its 1.14 entry paired it with OTP 23, which the current image does not ship. Happy to pull that out if you would rather take it on its own.
  • mix test is green locally on Elixir 1.19.5 / OTP 28; the matrix in CI is what checks 1.14/OTP 24 and 1.17/OTP 27.

Happy to split this further, or to cut the pinning API out into its own PR if you would rather land the address validation first.

Fixes CVE-2026-77866 and CVE-2026-77972, which affect every release.

Only the first IPv4 address of a host was matched against the reserved
ranges and the blocklist, and anything else matched nothing. A host
rejected in its IPv4 form was accepted written as an IPv6 address, IPv6
entries in a blocklist never matched, and a host that resolved to no
IPv4 address was accepted whatever it pointed at. The host is now
resolved once and every address it has, in both families, must pass.
IPv4-mapped (::ffff:10.0.0.1) and IPv4-compatible (::10.0.0.1) addresses
are checked as the IPv4 address they carry, the reserved list covers the
IPv6 blocks IANA marks as not globally reachable, and a host without any
address is rejected with the new :unresolved_host rather than let
through. The default resolver, SafeURL.DNS, looks up A and AAAA records
and bounds each query, which was previously unbounded.

Validation also returned a verdict and not the address it approved, so
the hostname went to an HTTP client that resolved it a second time. A
name under an attacker's control could answer with a public address for
the check and an internal one for the request, and the same window opens
on its own whenever a name legitimately resolves to different addresses
across lookups. pin/2 returns the URL with the validated address in
place of the host, plus the hostname, address, scheme and port, so a
caller connects to what passed while the name still governs the Host
header, the server name and the certificate check.

The advisory names the HTTP clients this library ships, and both used
the old pattern. SafeURL.HTTPoison.get/3 now pins the request and
rebuilds hackney's TLS options from the hostname, because supplying
:ssl_options replaces hackney's defaults rather than adding to them; a
caller who sets :ssl_options or :insecure keeps their own setup, and
validation options go under :safeurl. SafeURL.TeslaMiddleware pins any
request without TLS and https on Tesla.Adapter.Hackney; with another
adapter it validates but cannot pin, since keeping certificate
verification pointed at the hostname is spelled differently by each one,
and the docs say so.

Both clients refuse :follow_redirect, given directly or through the
adapter options, because a redirect names a destination that was never
validated; hackney was asked to follow one and reached a link-local
metadata address. With Tesla, placing the middleware after
Tesla.Middleware.FollowRedirects validates every hop instead.

The CI matrix moves to a runner image that still exists, and
guides/migrating_to_1.1.md covers the URLs 1.0.0 accepted and this
release rejects. The version stays on the 1.x line so that a "~> 1.0"
dependency picks the fix up.
SafeURL.Client.hackney_ssl_options/1 calls
:hackney_ssl.check_hostname_opts/1, which is exported but undocumented
and does not exist before hackney 1.16.0. httpoison pulls a newer one in
practice, but Tesla's hackney adapter allows ~> 1.6, so an https request
pinned through the middleware could raise UndefinedFunctionError at
request time instead of being sent. hackney joins the optional
dependencies with that floor, so the resolver refuses the combination
rather than the request.

The package shipped mix.exs, lib and README.md only, while docs: extras
points at the migration guides, so they were listed in the docs and
missing from the tarball. Both guides ship now.

Covers the client helpers too: the host header replaces one the caller
set and carries the port when it is not the scheme default, the rebuilt
TLS options verify against the hostname rather than the pinned address,
and :follow_redirect raises.
@DiedeClaessens

Copy link
Copy Markdown
Author

Also linked to #9

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant