Skip to content

fix: strip hop-by-hop headers on the default transport, not only http2 - #489

Open
pacocartones wants to merge 5 commits into
fastify:mainfrom
pacocartones:fix/strip-hop-by-hop-headers-default-transport
Open

fix: strip hop-by-hop headers on the default transport, not only http2#489
pacocartones wants to merge 5 commits into
fastify:mainfrom
pacocartones:fix/strip-hop-by-hop-headers-default-transport

Conversation

@pacocartones

Copy link
Copy Markdown

What

The http2 request path strips hop-by-hop headers (RFC 7230 §6.1) via stripHttp1ConnectionHeaders, but the default (undici) and core-http paths only removed the names listed in the client's Connection header. So te, proxy-connection, keep-alive and upgrade sent by a client were forwarded to the upstream over the default transport — headers a proxy is supposed to consume, not pass on.

This is an HTTP/1 vs HTTP/2 parity gap, not a smuggling issue (I checked: Content-Length + Transfer-Encoding together already yield a 400 and nothing reaches the upstream).

Reproduction, with controls

Proxy @fastify/reply-from on its default transport → a raw net upstream that captures the exact request line:

CONTROL  no special headers        -> upstream sees x-normal, nothing odd     ok
CONTROL  header listed in Connection-> x-secret removed                        ok
FUGA     te: gzip                   -> upstream receives it                    ✗
FUGA     proxy-authorization        -> upstream receives it                    ✗

Measuring what the http2 path already removes vs what the default path forwarded:

header default (before) http2
te forwarded stripped
proxy-connection forwarded stripped
keep-alive forwarded stripped
upgrade forwarded stripped

The fix

Build the forwarded request headers through the same stripHttp1ConnectionHeaders the http2 path already uses, so there is one source of truth for what is hop-by-hop. This also keeps the existing behaviour of dropping Connection-listed names before rewriteRequestHeaders runs (that function is a superset of the old manual logic).

TE: trailers is preserved, matching the http2 path and Node.

Tests

Added to test/strip-connection-headers.test.js for both the undici and core-http transports: the new tests fail on the previous code and pass here. I also kept a TE: trailers case to lock in that it is not stripped. Full npm test is green (unit + tstyche).

Scope note

I kept this focused on the parity gap. proxy-authorization and trailer are hop-by-hop too (RFC 7235 / 7230) but were forwarded on both transports — stripHttp1ConnectionHeaders doesn't list them. Happy to extend the list in a follow-up if you'd like; I left it out to keep this change to the asymmetry.

A media type is case-insensitive and may be followed by parameters
(RFC 9110 section 8.3.1), but the check that disables the upstream
request timeout for server-sent events compared the raw `content-type`
header with strict equality against `text/event-stream`.

Upstreams that answer `text/event-stream; charset=utf-8` therefore fall
straight through it. Starlette's `EventSourceResponse` and Spring's
`text/event-stream;charset=UTF-8` are two common ones. The timeout is
never cleared, it fires while the stream is idle between events, and
`req.abort()` then runs after the response headers have already been
forwarded downstream: the client gets a truncated stream and the proxy
throws an uncaught `ERR_HTTP_HEADERS_SENT`.

Parse the header instead of comparing it. `fast-content-type-parse` is
already a direct dependency and `index.js` uses it the same way, so this
adds no new dependency. `safeParse` normalises the case and drops the
parameters, and never throws on a malformed or absent header.

Signed-off-by: Manuel Sánchez <mpktmpktmpktmpkt@gmail.com>
The two tests added by this PR were too tight for CI. The http2 one hung and
took the whole file past the 30s limit in 10 of 11 jobs.

The session timer is armed when the http2 client connects, so `sessionTimeout:
100` had to cover connecting plus the target's first response. On a loaded
runner that does not fit: in the same file `http2 request timeout` took 6167ms
where it takes 325ms locally. When the timer fires before the response arrives,
`done` is called a second time after the downstream headers are already sent,
the proxied response is never terminated, and the test waits forever rather
than failing.

Both SSE tests now use a 1000ms timeout with a 2000ms quiet period, keeping the
quiet period comfortably longer than the timeout, which is the property under
test. The http1 sibling passed everywhere but has the same shape, so it gets
the same headroom rather than waiting for it to flake later.

Still red without the fix, and for the right reason:
  http1  ERR_HTTP_HEADERS_SENT, uncaught, headers already sent
  http2  received 'data: first\n\n', expected 'data: first\n\ndata: last\n\n'

Verified locally: lint clean, 166 unit tests pass over three consecutive runs,
tstyche 13/13.

Signed-off-by: Manuel Sánchez <mpktmpktmpktmpkt@gmail.com>
Addresses the review: the two integration tests I added armed a proxy timeout
and then a longer setTimeout on the target, racing two clocks. On a loaded
runner the timeout could fire before the first response and the test hung —
exactly the flakiness flagged.

The regression is purely about parsing the response content-type (a media type
is case-insensitive and may carry parameters, RFC 9110 §8.3.1). That is a pure
function, so it is now tested as one: `isServerSentEvents` is exported and
test/sse-content-type.test.js covers bare, parametrised, uppercase, negative
and empty inputs with no server, socket or timer. Reverting the function to the
old strict `=== 'text/event-stream'` turns those cases red, which is the bug.

The two integration tests keep their end-to-end coverage but drop the timers:
the target now answers the SSE response immediately and the test asserts 200
plus the body, the same shape as the sibling `* sse removes timeout test`
already in each file. No setTimeout, no race.
The http2 request path removed hop-by-hop headers (RFC 7230 §6.1) via
stripHttp1ConnectionHeaders, but the default (undici) and core-http paths only
dropped the names listed in the client's Connection header. As a result `te`,
`proxy-connection`, `keep-alive` and `upgrade` supplied by the client were
forwarded to the upstream over the default transport, which is not what a proxy
should do with headers meant to be consumed hop-by-hop.

Reproduced with controls (proxy -> raw upstream capturing the exact request
line): `te` and `keep-alive` reached the upstream; a Connection-listed header
was correctly removed (control). This closes the HTTP/1 vs HTTP/2 gap by
building the forwarded headers through the same stripHttp1ConnectionHeaders the
http2 path already uses, so there is a single source of truth for what is
hop-by-hop.

`TE: trailers` is preserved, matching the existing http2 behaviour and Node.

Tests added in strip-connection-headers.test.js for both transports; they fail
on the previous code and pass here. Full suite green.
The new SSE http2 test registered t.after(target.close) before
t.after(instance.close), and t.after hooks run FIFO. The SSE response
disarms the plugin's http2 session timeout, so that session only dies
when instance.close() destroys it. Before Node 24 an http2 server's
close() waits for open sessions, so on the Node 20/22 CI runners
target.close() waited on a session nothing else could close and the
file hit the 30s test timeout with every subtest green. Registering
instance.close() first makes the teardown order match the dependency.
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