Skip to content

http2: a pseudo-header in trailers, repeated in a block, after a regular field or unknown resets the stream, so a handler can no longer be sent to another path - #513

Open
MDA2AV wants to merge 1 commit into
mainfrom
fix/503-http2-pseudo-headers
Open

MDA2AV wants to merge 1 commit into
mainfrom
fix/503-http2-pseudo-headers

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

PendingRequest.AddHeader stored whatever pseudo-header it decoded, with no check of where it appeared. On a buffered request:

  • In trailers: POST /public, some DATA, then trailers with :path: /admin, and the handler saw /admin.
  • Repeated in a block: the last :path won.
  • After a regular field, or unknown: accepted.

RFC 9113 8.1 and 8.3 make all of these malformed. It matters behind anything that decides from the first value and forwards the frames, such as a proxy that authorizes /public.

Details are in #503.

The fix

The nghttp2 stack does not have this: nghttp2_http_on_header refuses such fields in the library. #514 is the managed HTTP/3 stack's version.

Behaviour

  • A request with any of these is now reset, where it used to be served.
  • Trailers with regular fields still merge into the request, as before.

API

None.

Tests

New in the Unit suite (Http2PseudoHeaderTests.cs), over the strict test transport:

  • h2 pseudo-header: :path, which the request has, in trailers resets the stream, and no handler runs
  • h2 pseudo-header: :authority, which it lacks, in trailers resets the stream, and no handler runs
    • This one is only caught by the end of the block closing them, since it repeats nothing.
  • h2 pseudo-header: a repeated :path resets the stream before any handler runs, and HPACK stays in step
  • h2 pseudo-header: one after a regular field resets the stream before any handler runs, and HPACK stays in step
  • h2 pseudo-header: an unknown one resets the stream before any handler runs, and HPACK stays in step
    • These three run with both handler shapes, one taking a body reader. Each block also inserts a field into the table, and the next request refers to it.

Against main's ioxide.http2 (the tests only):

FAIL  h2 pseudo-header: :path, which the request has, in trailers resets the stream, and no handler runs: expected [1 0x1], got []
FAIL  h2 pseudo-header: :authority, which it lacks, in trailers resets the stream, and no handler runs: expected [1 0x1], got []
FAIL  h2 pseudo-header: a repeated :path resets the stream before any handler runs, and HPACK stays in step: expected [1 0x1], got []
FAIL  h2 pseudo-header: a repeated :path resets the stream before any handler taking a body reader runs, and HPACK stays in step: expected [1 0x1], got []
FAIL  h2 pseudo-header: one after a regular field resets the stream before any handler runs, and HPACK stays in step: expected [1 0x1], got []
FAIL  h2 pseudo-header: one after a regular field resets the stream before any handler taking a body reader runs, and HPACK stays in step: expected [1 0x1], got []
FAIL  h2 pseudo-header: an unknown one resets the stream before any handler runs, and HPACK stays in step: expected [1 0x1], got []
FAIL  h2 pseudo-header: an unknown one resets the stream before any handler taking a body reader runs, and HPACK stays in step: expected [1 0x1], got []
0 passed, 8 failed, 0 skipped

Each clause was also run against a mutation that removes it, and only its own tests failed: repeats, a regular field closing them, the end of a block closing them, and unknown names.

With the fix:

  • Unit: 153 passed.
  • E2E: 453 passed.
  • Chaos: 83 passed.
  • Http: 95 passed.

Merging

#486 pools PendingRequest, so Dispose resets these here already.

Bench

Measured with the same setup as #486:

  • One reactor pinned to CPU 6, and h2load -c 32 -m 16 -t 1 pinned to CPU 2.
  • Main, this branch, and main again from a second worktree as a control.
  • Six interleaved rounds of 10 s; each build is compared with main inside its own round.
Sample Server CPU per request, branch vs main Control vs main
Http2/ManagedBuffered /hello +0.7% +2.1%

Inside the noise, and the control moved more than the branch.

Fixes #503.

…lar field or unknown resets the stream, so a handler can no longer be sent to another path

Each was stored over the request's own, or ignored. RFC 9113 8.1 and 8.3 make all of them malformed. The block is still decoded, to keep HPACK in step.

Fixes #503.

This branch has not been deployed

No deployments
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.

http2: a pseudo-header in trailers, or repeated in a block, replaces the request's, so a handler can see another path

1 participant