CVE-2026-105660: an HTTP/1.1 request can name its host twice
Bandit, the HTTP server new Phoenix applications use by default, checked the Host header against the URI grammar and answered 400 when it failed. It never checked the other place a request can name its host, the absolute-form request target, and the HTTP standard says that is the one that wins. So the bytes Bandit refused in one place reached conn.host from the other. An earlier CVE had fixed the scheme in the same four-part tuple and stopped there.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it was written byte by byte to a raw socket inside a container with networking disabled. The issue was reported privately through the project’s security advisory process. It is written up here after the advisory, the fixed release and the CVE record were public.
Most HTTP/1.1 requests name the host they are for in a header:
GET /reset HTTP/1.1
Host: shop.example
A request can also put the whole URL in the request line. This is the absolute form, the shape a client uses when it talks to a proxy:
GET http://shop.example/reset HTTP/1.1
Host: shop.example
Now the request names its host twice. Usually the two names agree. When they don’t, a server has to pick one, and it has to decide what it is willing to accept in each place. I found a case where Bandit had answered those two questions differently.
Which one wins?
The standard is explicit. RFC 9112 §3.2.2 says that when the request target is in absolute form, the server must ignore Host and use the authority from the target. Bandit does this, and Bandit’s own source says why. A pull request merged in August, 5a465ca, which tightened Host validation, spells out the rule in its description:
“When an absolute-form request-target is present, the request-target authority still takes precedence when constructing the effective request URI. Validating
Hostdoes not change that routing rule.”
That is correct. The value from the request line is the one that becomes conn.host, which every Plug and Phoenix application reads.
Which one is checked?
The same pull request made the Host header answer to the RFC 3986 grammar for a host: a registered name, or a well-formed IP literal, with an optional port between 0 and 65535. Anything else gets a 400. HTTP/2’s :authority already went through the same parser.
Here is where the request-target authority ended up, in lib/bandit/pipeline.ex:
defp determine_host_and_port!(scheme, :"HTTP/1.1", {_, target_host, target_port, _}, headers) do
{header_host, header_port} = required_host_header!(headers) # the RFC 3986 grammar
case target_host do
nil -> {header_host, header_port || URI.default_port(scheme)}
target_host -> {to_string(target_host), target_port || URI.default_port(scheme)} # nothing
end
end
Host is required, parsed and validated, then thrown away whenever there is a target_host. The value that is kept is converted to a string and used as it is. For HTTP/1.0 a second clause skips Host altogether, so the absolute-form authority is the only input. The value comes from :erlang.decode_packet/3, which splits the request line into a tuple without asking whether the host part is a valid host.
So the strict grammar checked the value Bandit discards, and nothing checked the value Bandit keeps.
Are they really the same bytes?
A server answering 200 to a strange request proves very little on its own. The test has to show that Bandit refuses those exact bytes elsewhere, on the same build, in the same run. Everything below went over raw :gen_tcp sockets, with no HTTP client in between to tidy the bytes, inside an elixir:1.18-otp-27-alpine container with --network none. The test Plug echoed conn.host and conn.port, plus one line standing in for application code that builds an absolute link from conn.host.
Ten values, each sent once as Host: and once in the request target. Every target request also carried a legal Host: good.example, which Bandit validated and then ignored:
| value | in Host: |
in the request target |
|---|---|---|
good.example@evil.example |
400 | accepted, conn.host = "good.example@evil.example" |
ex#am.com |
400 | accepted |
ex?am.com |
400 | accepted |
ex NUL am.com |
400 | accepted, a NUL byte in conn.host |
ex DEL am.com |
400 | accepted |
ex 0xFF am.com |
400 | accepted, not valid UTF-8 |
ex\am.com |
400 | accepted |
[zz::1] |
400 | accepted, not an IP address |
ex.com:99999 |
400 | accepted, conn.port = 99999 |
ex.com:8x |
400 | ex.com, port 80 |
The left column is the control, and it held: all ten refused. The right column is the finding. The 99999 row breaks a second promise as well: Plug.Conn declares port_number() as 0..65535, and Bandit handed an application a value outside that range. The last row I’ll come back to.
The other two request shapes that carry an authority reach the same code. GET http://good.example@evil.example/x HTTP/1.0, with no Host header at all, was accepted. CONNECT good.example@evil.example:443 was accepted too, and set conn.port to the string "443", which makes Plug.Conn.request_url/1 raise. I reported that as a side observation, not as part of the finding.
What did the earlier fix move?
The tuple decode_packet produces is {scheme, host, port, path}. In May, Bandit fixed CVE-2026-39807, reported by PJUllrich: on a plaintext connection, a request target starting https:// made Bandit report the connection as secure. The fix took the first element of that tuple out of trusted input. The scheme now comes from the socket, not from the request.
The next line of build_conn! still trusted the second and third elements. My report described the finding as the same class, one element further along. That framing helped: I was not asking the maintainer to accept a new rule, only to apply one he had already applied, to the next field.
What can an application do with it?
On its own, conn.host is just a string. The risk comes from what applications build with it. Plug.Conn.request_url/1 turns it into a URL, Plug.SSL uses it as the host of its HTTPS redirect, and so does any application code that builds a link from it. Give them good.example@evil.example and they produce:
https://good.example@evil.example/reset?token=SECRET
A browser reads everything before the @ as a username and connects to evil.example. A person skimming the link sees good.example. If that URL carries a token, the token goes to the wrong host.
The report was explicit about what it had not built. I did not build an application around it or drive a password-reset flow end to end. The token=SECRET line was a stand-in, labelled as one. The demonstrated fact was narrower: one unauthenticated request could set conn.host to a string Bandit refuses on every other route into that field. I filed it at CVSS 3.1 6.5, C:L/I:L. The published advisory uses CVSS 4.0 and scores integrity only, VC:N/VI:L, 6.9 Medium. That is a fair reading of a bug whose direct effect is on what the application believes, while the leak depends on what the application then does.
Who else was looking?
I sent the report on 8 September. On 12 October Mat Trudel accepted it, asked Jonatan Männchen at the Erlang Ecosystem Foundation’s CNA for an identifier in the same minute, and opened a private fork. CVE-2026-105660 was reserved on 14 October.
In between, on 13 October, PJUllrich opened a public issue, #711, about control bytes in HTTP/2 pseudo-headers and in HTTP/1 request targets. Mat fixed it the next day in #713: any component of the request line containing a byte from 0x00 to 0x1F, DEL or a space now gets a 400. That covers two rows of my table, NUL and DEL, and it reached the public main branch before my advisory did. It does not touch the other eight rows. @, #, ?, 0xFF, a backslash, a malformed IP literal and an out-of-range port contain no control bytes. Those rows depend on the grammar check, and that check is what my report asked for.
Is it fixed?
The fix is 6ea8021, merged on 15 October at 16:39 UTC. It adds a function that joins the target’s host and port back into an authority string and sends it through the same parser Host uses:
# The request target's authority arrives from :erlang.decode_packet/3 already split into a host
# and a port [...], and it is not validated at all by decode_packet. Join it back
# up and hold it to the same grammar as the Host header
defp parse_target_authority!(host, port) do
authority = if is_nil(port), do: host, else: [host, ":", to_string(port)]
Bandit.Headers.parse_hostlike_header!(IO.iodata_to_binary(authority))
rescue
_error in Bandit.HTTPError -> request_error!("Request target contains invalid host")
end
Both clauses call it, so HTTP/1.0 is covered too. The changelog entry also makes conn.port an integer for CONNECT, which fixes my side observation in the same patch.
I ran the original probe, unchanged, against the fix and against its parent commit, which already includes #713:
| request target | parent 2758910 |
fix 6ea8021 |
|---|---|---|
good.example@evil.example, ex#am.com, ex?am.com |
accepted | 400 |
ex NUL am.com, ex DEL am.com |
400 (#713) | 400 |
ex 0xFF am.com, ex\am.com, [zz::1] |
accepted | 400 |
ex.com:99999 |
accepted, port 99999 | 400 |
HTTP/1.0 absolute form, no Host |
accepted | 400 |
CONNECT good.example@evil.example:443 |
accepted, port "443" |
400 |
ex.com:8x |
ex.com, port 80 |
ex.com, port 80 |
the ten Host: controls |
400 | 400 |
The last target row is unchanged, and the fix is not the reason. decode_packet itself turns http://ex.com:8x/x into {:absoluteURI, :http, "ex.com", :undefined, "/x"}. The malformed port is discarded before Bandit sees it, so the application receives a legal host and the default port. It is a lossy parse, but it lets nothing through that the grammar would refuse.
The advisory went public less than a minute after the merge, at 16:40 UTC, with CVE-2026-105660 in its identifier field and its affected range and patched version still reading TODO. For two hours the fix was on main but not in any version you could pin. Bandit 1.13.0 reached Hex at 18:36 UTC, the advisory’s range became >= 0.6.0 and < 1.13.0, and the ErlEF CNA published the CVE record a minute later. If you run Bandit, upgrade to 1.13.0. Where that has to wait, two things close the gap: a reverse proxy in front of Bandit that forwards requests in origin form, so an absolute-form target never reaches it, and building links from a configured host instead of from conn.host.
Advisory GHSA-v53v-9hvg-37jf · CVE CVE-2026-105660 · Fix 6ea8021 · Affected >= 0.6.0, < 1.13.0 · Fixed 1.13.0
Reported 8 September · accepted and CVE requested 12 October · CVE reserved by the ErlEF CNA 14 October · fix merged and advisory published 15 October, crediting me as reporter · 1.13.0 released and CVE record published the same day.
Eurico Nicacio — @h3llh0und