Skip to content

streams: reject connection-specific header fields in send_trailers (RFC 9113 §8.2.2) - #925

Merged
seanmonstar merged 3 commits into
hyperium:masterfrom
scadastrangelove:reject-connection-headers-in-send-trailers
Jul 28, 2026
Merged

streams: reject connection-specific header fields in send_trailers (RFC 9113 §8.2.2)#925
seanmonstar merged 3 commits into
hyperium:masterfrom
scadastrangelove:reject-connection-headers-in-send-trailers

Conversation

@scadastrangelove

Copy link
Copy Markdown
Contributor

streams: reject connection-specific header fields in send_trailers (RFC 9113 §8.2.2)

Summary

RFC 9113 §8.2.2 forbids connection-specific header fields (Connection, Keep-Alive,
Proxy-Connection, Transfer-Encoding, Upgrade, and TE other than TE: trailers) in HTTP/2, in
both directions:

An intermediary transforming an HTTP/1.x message to HTTP/2 [...] MUST NOT generate an HTTP/2
message containing connection-specific header fields; any message containing connection-specific
header fields MUST be treated as malformed.

h2 already enforces both halves of that — except on one send path. Trailers are carried in a
HEADERS frame, so they are subject to the same rule, but Send::send_trailers is the only outbound
HEADERS path that does not validate its fields:

Path Validates connection-specific headers?
send_headers (send.rs) check_headers
send_push_promise check_headers
send_interim_informational_headers check_headers
send_trailers none
receive (frame/headers.rs load_hpack) ✅ rejects as malformed

As a result, a caller that puts e.g. transfer-encoding into a trailer HeaderMap makes h2
generate and transmit a TRAILERS frame carrying a header the same crate (a) rejects with
UserError::MalformedHeaders on every other send call and (b) treats as a malformed message,
PROTOCOL_ERROR, on receive. The public SendStream::send_trailers docs do not state that the caller
is responsible for this, so the inconsistency is silent.

Fix

Call the existing check_headers from send_trailers, before the state transition, so a rejected call
returns UserError::MalformedHeaders and leaves the stream able to send valid trailers — exactly the
policy the other three send paths and the receive path already apply. No new policy, just consistency.

Self::check_headers(frame.fields())?;

check_headers already encodes the TE: trailers exception, so valid trailers (including TE: trailers) are unaffected. This mirrors the receive path byte-for-byte.

Why it matters

This is primarily a conformance / internal-consistency fix: h2 should not generate a message it
defines as malformed on receipt. It is not remotely exploitable against a conformant peer — a compliant
HTTP/2 receiver (including h2 itself) resets the stream with PROTOCOL_ERROR, so the exchange fails
closed. The relevance is for deployments where h2 output reaches a lenient or downgrading downstream
(e.g. an HTTP/2→HTTP/1.1 gateway that serializes trailers without re-filtering), where a
connection-specific header smuggled in a trailer is a known request-smuggling primitive. Emitting it at
all is the part this crate controls, and §8.2.2 says not to.

The gap is reachable in practice through hyper: as a reverse proxy, hyper forwards an upstream
response/request body's trailers verbatim (its strip_connection_headers is applied only to the main
head, not to the trailer block), so a trailer set by a malicious or misbehaving upstream is handed
straight to send_trailers. This was demonstrated end to end — a malicious HTTP/1.1 backend's
transfer-encoding trailer, forwarded through a hyper HTTP/2 reverse proxy, is re-emitted on the
HTTP/2 wire and rejected by the compliant client with PROTOCOL_ERROR. (Notably, hyper's own HTTP/1
trailer encoder already filters these fields via its is_valid_trailer_field allow-list; the HTTP/2
egress path relies on h2, where the check is currently absent — so this change also brings the two
egress directions into line.)

How this was verified

  • The behavior was reproduced by executing a proof of concept: a hyper HTTP/2 client sending a body
    whose trailers carry transfer-encoding: chunked. Frame tracing shows h2 writing the trailer
    HEADERS frame onto the wire (send_trailers -- queuingframed_write: send frame=Headers{… END_STREAM}); a compliant h2 server rejects it (load_hpack; connection level headermalformed messagestream error PROTOCOL_ERROR). A benign trailer on the same path is accepted — a clean
    differential.
  • The proxy reachability was reproduced separately: a raw HTTP/1.1 backend emitting a transfer-encoding
    trailer, forwarded through a hyper h2-server / h1-client reverse proxy, is re-emitted on the
    HTTP/2 wire (the h2 client resets with PROTOCOL_ERROR), while a benign trailer forwards cleanly. The
    same backend behind an h1-server / h1-client hyper proxy has its transfer-encoding trailer
    filtered out — confirming the HTTP/1 encoder already enforces this and only the HTTP/2 path does not.
  • The included regression test (send_trailers_rejects_connection_specific_headers) fails on master
    (the poisoned trailer is accepted and the stream is cancelled instead of completing) and passes with
    this change. The full h2-tests suite (202 passing tests across 12 files) stays green.

Test

tests/h2-tests/tests/trailers.rs::send_trailers_rejects_connection_specific_headers asserts that
connection, keep-alive, proxy-connection, transfer-encoding, upgrade, and te: <not trailers>
are each rejected in a trailer block with user error: malformed headers, and that a subsequent valid
trailer still sends and the exchange completes — confirming the rejection does not corrupt stream state.


Found and verified with rust-in-peace, an
AI-agent-assisted Rust security-review pipeline; the diff and test were authored and run against the
crate at current master.

scadastrangelove and others added 3 commits July 26, 2026 14:29
…FC 9113 §8.2.2)

Trailers are carried in a HEADERS frame and are subject to the same prohibition
on connection-specific header fields (RFC 9113 §8.2.2) as every other outbound
HEADERS block. `send_headers`, `send_push_promise` and
`send_interim_informational_headers` all reject these via `check_headers`, and
the receive path (`load_hpack`) treats such a header as malformed — but
`send_trailers` did not, making it the only send path that would *generate* a
message §8.2.2 forbids. Apply the same `check_headers` before the state
transition, so a rejected call leaves the stream able to send valid trailers.

Adds a regression test asserting that connection/keep-alive/proxy-connection/
transfer-encoding/upgrade and a non-"trailers" TE are each rejected in a trailer
block, and that a subsequent valid trailer still sends.

@seanmonstar seanmonstar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks :)

@seanmonstar
seanmonstar merged commit 46bfd62 into hyperium:master Jul 28, 2026
6 checks passed
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.

2 participants