From 9472aa8f6439194eb1e41b7c6db3dd7539a01226 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Thu, 5 Nov 2026 22:32:15 +0000 Subject: [PATCH] http3: a repeated or unknown pseudo-header, or one after a regular field, 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 9114 4.3 makes all of them malformed, so the stream gets H3_MESSAGE_ERROR, as an invalid content-length does. Fixes #504. --- src/protocols/ioxide.http3/Http3Connection.cs | 6 +++ src/protocols/ioxide.http3/Http3Request.cs | 20 ++++++-- .../Protocols/H3ErrorCodeTests.cs | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) 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();