From 01ad5aea8d03dcb4a23333fdedf987cacd784d4c Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 22:30:11 +0000 Subject: [PATCH] http2: HEADERS without END_STREAM after the block that opens a request resets the stream, so its fields no longer land in the body They were decoded into the arena the body was being appended to, and the body's one range ran across them, losing as many bytes off its end. RFC 9113 8.1 makes such a request malformed. The block is still decoded, to keep HPACK in step. Fixes #502. --- .../ioxide.http2/Http2Connection.Frames.cs | 8 +++ tests/Ioxide.Tests.Unit/Http2TrailerTests.cs | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/protocols/ioxide.http2/Http2Connection.Frames.cs b/src/protocols/ioxide.http2/Http2Connection.Frames.cs index 222c4586..65757ca1 100644 --- a/src/protocols/ioxide.http2/Http2Connection.Frames.cs +++ b/src/protocols/ioxide.http2/Http2Connection.Frames.cs @@ -190,6 +190,14 @@ private void HandleHeaders(in FrameHeader header, ReadOnlySpan payload) } return; } + else if ((header.Flags & FrameFlags.EndStream) == 0) + { + // RFC 9113 8.1: after the block that opens a request come only trailers, which end its stream. + _discardBlock.StreamId = header.StreamId; + DiscardHeaderBlock(header, block); + ResetStream(header.StreamId, Http2Error.ProtocolError); + return; + } // A header block can span HEADERS + CONTINUATION frames, and HPACK cannot be decoded // piecewise - the whole block has to be in hand first. diff --git a/tests/Ioxide.Tests.Unit/Http2TrailerTests.cs b/tests/Ioxide.Tests.Unit/Http2TrailerTests.cs index 1e1db4d5..6cd6e7f3 100644 --- a/tests/Ioxide.Tests.Unit/Http2TrailerTests.cs +++ b/tests/Ioxide.Tests.Unit/Http2TrailerTests.cs @@ -195,6 +195,56 @@ public static void Register(Runner runner) gate.SetResult(); peer.Close(run); }); + + // RFC 9113 8.1. Its fields landed in the arena the body was being appended to, and the body's range ran across them. + runner.Test("h2 buffered request: HEADERS without END_STREAM after the opening block resets the stream, and its body never reaches a handler", () => + { + var served = new List(); + + using var peer = new Peer(new Http2Options()); + Task run = peer.Connection.RunAsync(request => + { + served.Add($"{Ascii(request.Path)} {Fields(request)}"); + return Http2Response.Text("ok"); + }); + + peer.Open(); + peer.Headers(1, Request("/up"), endStream: false); + peer.Data(1, 400, endStream: false); + peer.Headers(1, Field("x-a", "abc", index: true), endStream: false); + peer.Data(1, 400, endStream: true); + peer.Headers(3, [.. Request("/next"), 0xBE], endStream: true); // the block was decoded, so x-a is at index 62 + peer.Close(run); + + Assert.Equal("1 PROTOCOL_ERROR", Resets(peer)); + Assert.Equal("", GoAways(peer)); + Assert.True(served.SequenceEqual(["/next x-a: abc"]), $"the handler was handed: {string.Join(", ", served)}"); + }); + + // The block's CONTINUATION is still owed after the reset, and has to reach the decoder, not end the connection. + runner.Test("h2 buffered request: HEADERS without END_STREAM split over CONTINUATION still keeps HPACK in step", () => + { + var served = new List(); + + using var peer = new Peer(new Http2Options()); + Task run = peer.Connection.RunAsync(request => + { + served.Add($"{Ascii(request.Path)} {Fields(request)}"); + return Http2Response.Text("ok"); + }); + + peer.Open(); + peer.Headers(1, Request("/up"), endStream: false); + byte[] block = Field("x-a", "abc", index: true); + peer.HeadersOpen(1, block[..3], endStream: false); + peer.Continuation(1, block[3..]); + peer.Headers(3, [.. Request("/next"), 0xBE], endStream: true); + peer.Close(run); + + Assert.Equal("1 PROTOCOL_ERROR", Resets(peer)); + Assert.Equal("", GoAways(peer)); + Assert.True(served.SequenceEqual(["/next x-a: abc"]), $"the handler was handed: {string.Join(", ", served)}"); + }); } // :method POST and :scheme http from the static table, and :path as a literal on its static @@ -343,6 +393,10 @@ public void Headers(int streamId, byte[] block, bool endStream) public void Continuation(int streamId, byte[] block) => Feed(FrameBytes(0x9, 0x4, streamId, block)); + /// A HEADERS frame without END_HEADERS: a CONTINUATION ends its block. + public void HeadersOpen(int streamId, byte[] block, bool endStream) + => Feed(FrameBytes(0x1, (byte)(endStream ? 0x1 : 0), streamId, block)); + public void Data(int streamId, int bytes, bool endStream) => Feed(FrameBytes(0x0, (byte)(endStream ? 0x1 : 0), streamId, Enumerable.Repeat((byte)'z', bytes).ToArray()));