Skip to content

Fix: Host header: brackets around an IPv6 address - #10

Open
neutrino38 wants to merge 7 commits into
witchtails:masterfrom
neutrino38:fix/websocket-host-ipv6
Open

neutrino38 wants to merge 7 commits into
witchtails:masterfrom
neutrino38:fix/websocket-host-ipv6

Conversation

@neutrino38

@neutrino38 neutrino38 commented Aug 31, 2026 •

Copy link
Copy Markdown

Base. Merge order: #7, then the TCP/SSL literal PR #8, then the UDP PR #9, then
this one.

The handshake now sends Host: [::1]:443. RFC 3986 §3.2.2 requires the
brackets: they tell the peer where the address ends and the port begins.

New function Socket.Address.to_uri_host/1 adds them and prints the canonical
form (RFC 5952). IPv4 addresses and host names come back unchanged, so nothing
moves outside IPv6. Socket.Web.connect/3 is its only caller, and arrives with
it.

Tests

Two in test/ipv6_test.exs, both measured to bite.

  • to_uri_host/1 over a tuple, a string, a charlist, a bracketed string, IPv4,
    a host name, and 2001:DB8:0:0:0:0:0:1 for the canonical form.
  • A handshake against a listener bound on ::1. The server sends back the
    Host header it read. Revert the web.ex line and the test fails:
    "::1:33845" against "[::1]:33845".

Elixir 1.18.3 / OTP 26: mix format --check-formatted passes, mix test is 18
tests / 0 failures over four runs, mix compile --warnings-as-errors fails with
the nine warnings master already has and no others.

A URI authority and a Host header carry an IPv6 literal inside square
brackets (RFC 3986 §3.2.2), and that is the form a caller holding an
address taken from a URI has in hand. parse/1 answered nil on it, so
every such caller had to strip the brackets itself, and the one who
forgot dialed the wrong address family with no diagnostic.

Only a v6 address is written that way: "[127.0.0.1]" stays nil, and so
does a bracket that never closes.
connect/3 turned every binary into a charlist and handed it to
:gen_tcp.connect/4, which resolves it as a name in the socket's family.
"::1" therefore came back :nxdomain on a socket nobody had declared
:inet6, while a tuple carries its own family. Parse first, and fall back
to a charlist only for what really is a host name.

The @SPEC already announced :inet.ip_address(), which the code could not
handle. It now says Socket.Address.t(), which it does.
connect/3 carried the same charlist conversion as Socket.TCP.connect/3,
with the same effect: the literal went to the resolver instead of to the
socket.

Its test reads a failure rather than a success — no TLS server answers on
the other end — because :nxdomain is what the resolution path returns and
a handshake timeout is not.
Socket.Datagram.send/3 takes a destination address in every form
Socket.Address.t() covers, and a literal goes to :gen_udp as a tuple.

Two things follow. The bracketed form a URI or a Host header carries,
"[::1]", now reaches its peer. And a destination given as a literal costs
no name resolution, on any datagram.
open/2 takes `v6only: true | false` and maps it to the socket's
ipv6_v6only. It is the last IPv6 setting the wrapper could not spell, and
an option it does not know raises rather than passes through.

It matters beyond the socket: an IPv6 socket that also accepts IPv4
receives from those peers under ::ffff:a.b.c.d, and every protocol
message that writes a peer address then carries that form. Only the
socket can be told otherwise.
The handshake now sends `Host: [::1]:443`. RFC 3986 §3.2.2 requires the
brackets: they tell the peer where the address ends and the port begins.

Socket.Address.to_uri_host/1 adds them and prints the canonical form
(RFC 5952). IPv4 addresses and host names come back unchanged.
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