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()));