Skip to content

http2: reject HEADERS and DATA frames received after END_STREAM - #47507

Open
citrus7 wants to merge 1 commit into
envoyproxy:mainfrom
citrus7:http2-reject-frames-after-end-stream
Open

citrus7 wants to merge 1 commit into
envoyproxy:mainfrom
citrus7:http2-reject-frames-after-end-stream

Conversation

@citrus7

@citrus7 citrus7 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Commit Message: http2: reject HEADERS and DATA frames received after END_STREAM

Additional Description:

RFC 9113 Section 5.1 requires an endpoint to treat frames other than PRIORITY, WINDOW_UPDATE and
RST_STREAM received on a stream in the "half-closed (remote)" state as an error. Envoy's HTTP/2
codec did not enforce this: ConnectionImpl::onHeaders() and ConnectionImpl::onBeginData()
unconditionally overwrote remote_end_stream_ and dispatched the frame to the stream's decoder.

On a client connection that decoder may already be gone. CodecClient::completeRequest()
deferred-deletes the ActiveRequest that owns the ResponseDecoder as soon as the response
completes, while oghttp2 keeps the stream in its map until it is also half-closed locally. An
upstream can arrange exactly that by pinning the stream send window to zero
(SETTINGS INITIAL_WINDOW_SIZE=0) so Envoy's request body is never written: half_closed_local
stays false, the response completes, and a subsequent HEADERS or DATA frame on the same stream is
dispatched through a dangling reference, aborting the process.

nghttp2 already rejects these frames, so this only changes behavior for oghttp2. A conforming peer
never sends frames after ending a stream and sees no change.

The check is placed in the codec rather than in the adapter so the invariant "no decoder callback
after remote END_STREAM" holds regardless of the underlying HTTP/2 library, and the runtime value
is latched per connection to keep a runtime lookup off the frame path.

Risk Level: Low. Guarded by envoy.reloadable_features.http2_reject_frames_after_end_stream
(default true); setting it to false restores the previous behavior.

Testing:

  • New integration tests in test/integration/http2_integration_test.cc, parameterized over
    IPv4/IPv6 x nghttp2/oghttp2:
    • DownstreamHeadersAfterEndStream / DownstreamDataAfterEndStream: a client that sends a
      second HEADERS or a DATA frame after ending its stream is rejected.
    • UpstreamHeadersAfterEndStream: the zero-window case described above; Envoy now reports an
      upstream protocol error instead of dispatching into a destroyed decoder.
    • DownstreamHeadersAfterEndStreamGuardDisabled: guard-off coverage (nghttp2 only, since with
      the guard off oghttp2 exhibits the defect being fixed).
  • With the guard disabled, UpstreamHeadersAfterEndStream aborts with
    libc++abi: Pure virtual function called! while dispatching trailers on a stream whose
    remote_end_stream_ is already set, confirming the test covers the regression.
  • //test/integration:http2_integration_test, //test/integration:multiplexed_integration_test
    and //test/common/http/http2:codec_impl_test all pass.

Docs Changes: n/a

Release Notes: changelogs/current/bug_fixes/http2__reject-frames-after-end-stream.rst

Platform Specific Features: n/a

RFC 9113 Section 5.1 requires an endpoint to treat frames other than
PRIORITY, WINDOW_UPDATE and RST_STREAM received on a stream in the
"half-closed (remote)" state as an error. Envoy's HTTP/2 codec did not
enforce this: onHeaders() and onBeginData() unconditionally overwrote
remote_end_stream_ and dispatched the frame to the stream's decoder.

On a client connection that decoder may already be gone.
CodecClient::completeRequest() deferred-deletes the ActiveRequest that
owns the ResponseDecoder as soon as the response completes, while
oghttp2 keeps the stream in its map until it is also half-closed
locally -- for example when the upstream pins the stream send window to
zero so that Envoy's request body is never written. A subsequent HEADERS
or DATA frame from that upstream is then dispatched through a dangling
reference, which aborts the process. nghttp2 already rejects these
frames, so this only changes behavior for oghttp2.

The check is guarded by
envoy.reloadable_features.http2_reject_frames_after_end_stream, latched
per connection to keep a runtime lookup off the frame path.

Signed-off-by: Jonathan Wu <jtwu@google.com>
@repokitteh-read-only

Copy link
Copy Markdown

CC @envoyproxy/runtime-guard-changes: FYI only for changes made to (source/common/runtime/runtime_features.cc).

🐱

Caused by: #47507 was opened by citrus7.

see: more, trace.

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