diff --git a/src/protocols/ioxide.http3/Http3Connection.cs b/src/protocols/ioxide.http3/Http3Connection.cs index 0b7e3fc5..ef063d82 100644 --- a/src/protocols/ioxide.http3/Http3Connection.cs +++ b/src/protocols/ioxide.http3/Http3Connection.cs @@ -545,6 +545,12 @@ private void OnHeadersComplete(long sid, ReqStream rs) return; } + if (rs.Request.Malformed) + { + RefuseMalformed(sid, rs, "a pseudo-header in the request is repeated, unknown or after a regular field"); + return; + } + if (_streamBodies) { var sink = new Http3BodyReader(this, sid); diff --git a/src/protocols/ioxide.http3/Http3Request.cs b/src/protocols/ioxide.http3/Http3Request.cs index 11435dbc..2c297cf1 100644 --- a/src/protocols/ioxide.http3/Http3Request.cs +++ b/src/protocols/ioxide.http3/Http3Request.cs @@ -37,16 +37,19 @@ public sealed class Http3Request // The DATA payload content-length still owes: -1 without one, -2 for an invalid or repeated one. internal long ContentLengthLeft = -1; + // A pseudo-header out of place: the stream is refused once the section is decoded, never dispatched. + internal bool Malformed; + // The QPACK decoder lands every decoded field here; pseudo-headers route by byte compare. internal void AddField(ReadOnlySpan name, ReadOnlySpan value) { if (name.Length > 0 && name[0] == (byte)':') { - (int Off, int Len) r = Append(value); - if (name.SequenceEqual(":method"u8)) MethodR = r; - else if (name.SequenceEqual(":path"u8)) PathR = r; - else if (name.SequenceEqual(":scheme"u8)) SchemeR = r; - else if (name.SequenceEqual(":authority"u8)) AuthorityR = r; + if (name.SequenceEqual(":method"u8)) Pseudo(ref MethodR, value); + else if (name.SequenceEqual(":path"u8)) Pseudo(ref PathR, value); + else if (name.SequenceEqual(":scheme"u8)) Pseudo(ref SchemeR, value); + else if (name.SequenceEqual(":authority"u8)) Pseudo(ref AuthorityR, value); + else Malformed = true; return; } @@ -64,6 +67,13 @@ internal void AddField(ReadOnlySpan name, ReadOnlySpan value) } } + // RFC 9114 4.3: each at most once, and ahead of every regular field. + private void Pseudo(ref (int Off, int Len) range, ReadOnlySpan value) + { + Malformed |= range.Len >= 0 || HeaderRanges.Count > 0; + range = Append(value); + } + private (int Off, int Len) Append(ReadOnlySpan data) { if (Arena.Length - ArenaUsed < data.Length) diff --git a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs index 70d0da08..3586d7e0 100644 --- a/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs +++ b/tests/Ioxide.Tests.E2E/Protocols/H3ErrorCodeTests.cs @@ -39,6 +39,7 @@ internal static class H3ErrorCodeTests private const ulong H3FrameError = 0x0106; private const ulong H3ExcessiveLoad = 0x0107; private const ulong H3RequestIncomplete = 0x010d; + private const ulong H3MessageError = 0x010e; private const ulong QpackDecompressionFailed = 0x0200; // A GET framed by hand: HEADERS(len 6), field-section prefix 00 00 (Required Insert Count 0, @@ -62,6 +63,52 @@ public static void Register(Runner runner) private static void RegisterCodeSeam(Runner runner) { + // RFC 9114 4.3. Hand-framed like WellFormedGet: 0xD1 :method GET, 0xD7 :scheme https, 0xC1 :path /, 0xC0 :authority "". + byte[] field = [0x23, (byte)'x', (byte)'-', (byte)'a', 0x01, (byte)'v']; + byte[] admin = [0x51, 6, .. "/admin"u8.ToArray()]; // :path, as a literal on its static name + foreach ((string name, byte[] fields) in new[] + { + ("a repeated :method", (byte[])[0xD1, 0xD7, 0xC1, 0xC0, 0xD1]), + ("a repeated :scheme", (byte[])[0xD1, 0xD7, 0xC1, 0xC0, 0xD7]), + ("a repeated :path", (byte[])[0xD1, 0xD7, 0xC1, 0xC0, .. admin]), + ("a repeated :authority", (byte[])[0xD1, 0xD7, 0xC1, 0xC0, 0xC0]), + ("a pseudo-header after a regular field", (byte[])[0xD1, 0xD7, 0xC1, .. field, 0xC0]), + ("an unknown pseudo-header", (byte[])[0xD1, 0xD7, 0xC1, 0xC0, 0x22, (byte)':', (byte)'x', 0x01, (byte)'v']), + }) + { + string shape = name; + byte[] headers = [0x01, (byte)(fields.Length + 2), 0x00, 0x00, .. fields]; + + runner.Test($"http3/codes: {shape} is H3_MESSAGE_ERROR on its stream, which no handler sees", () => + { + var quic = new RecordingQuic(); + Task run = StartPure(quic, out Served served, closeTransport: true, (0, headers, true), (4, WellFormedGet, true)); + + Assert.True(run.IsCompleted, "the run loop should complete inline once the transport closes"); + Assert.True(quic.Resets.SequenceEqual([(0L, H3MessageError)]), + $"expected stream 0 reset with H3_MESSAGE_ERROR and nothing else, got [{string.Join(", ", quic.Resets)}]"); + Assert.Equal(1, served.Count); // stream 4 alone + Assert.True(quic.ClosedWith is null, "one malformed request closed the connection"); + }); + + runner.Test($"http3/codes: {shape} never reaches a handler taking a body reader", () => + { + var quic = new RecordingQuic(); + Assert.True(quic.EnqueueStreamData(0, headers, true), "the recv ring rejected the item"); + quic.MarkClosed(); + + int served = 0; + Task run = new Http3Connection(quic).RunAsync((_, _) => + { + served++; + return new ValueTask(Http3Response.Text("ok")); + }); + + Assert.True(run.IsCompleted, "the run loop should complete inline once the transport closes"); + Assert.Equal(0, served); + }); + } + runner.Test("http3/codes: control - a hand-framed GET is served through the recording transport", () => { var quic = new RecordingQuic();