From 0b07ed5401a83119f930751a44c8dbef670a55c2 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 16:02:29 +0000 Subject: [PATCH 1/4] Update Glyph11 to 0.4.0 and parse with TryParseRequestHeader Glyph11 0.4.0 marks TryExtractFullHeaderValidated and TryExtractFullHeaderROM [Obsolete]: they report one less than the length of the header block, which is why every call site here sliced at bytesRead + 1. TryParseRequestHeader reports the length itself, so the body starts at consumed. With TreatWarningsAsErrors the update does not build without the move, and the rename and the dropped + 1 have to land together - renaming alone would skip the first byte of every body or pipelined request. Both engines, the body lifecycle tests and the benchmark context move. Glyph11.Pico stays at 0.0.1: PicoParser.TryParse keeps its convention, so the Pico path keeps its + 1. Closes #972. --- Benchmarks/Infrastructure/BenchmarkContext.cs | 2 +- Engine/Internal/GenHTTP.Engine.Internal.csproj | 2 +- Engine/Internal/Protocol/ClientHandler.cs | 4 ++-- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 2 +- Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs | 4 ++-- Engine/Shared/GenHTTP.Engine.Shared.csproj | 2 +- .../Acceptance/Engine/Body/RequestBodyLifecycleTests.cs | 8 ++++---- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Benchmarks/Infrastructure/BenchmarkContext.cs b/Benchmarks/Infrastructure/BenchmarkContext.cs index 1651292c8..3dc8c1050 100644 --- a/Benchmarks/Infrastructure/BenchmarkContext.cs +++ b/Benchmarks/Infrastructure/BenchmarkContext.cs @@ -29,7 +29,7 @@ public BenchmarkContext(string request, IHandler handler) public async ValueTask Execute() { - if (!UltraHardenedParser.TryExtractFullHeaderROM(ref _request, _context.Request.Source, Limits, out _)) + if (!UltraHardenedParser.TryParseRequestHeader(ref _request, _context.Request.Source, Limits, out _)) { throw new ArgumentException("Given request could not be parsed."); } diff --git a/Engine/Internal/GenHTTP.Engine.Internal.csproj b/Engine/Internal/GenHTTP.Engine.Internal.csproj index 256090769..ab2376d47 100644 --- a/Engine/Internal/GenHTTP.Engine.Internal.csproj +++ b/Engine/Internal/GenHTTP.Engine.Internal.csproj @@ -18,7 +18,7 @@ - + diff --git a/Engine/Internal/Protocol/ClientHandler.cs b/Engine/Internal/Protocol/ClientHandler.cs index 65e02b645..baa5b7f94 100644 --- a/Engine/Internal/Protocol/ClientHandler.cs +++ b/Engine/Internal/Protocol/ClientHandler.cs @@ -185,12 +185,12 @@ private async ValueTask HandlePipeAsync(PipeReader reader) [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) { - if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead)) + if (!UltraHardenedParser.TryParseRequestHeader(ref buffer, into, Limits, out var consumed)) { return false; } - buffer = buffer.Slice(bytesRead + 1); + buffer = buffer.Slice(consumed); return true; } diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 1f848c8ea..4a8f00f79 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -22,7 +22,7 @@ - + diff --git a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs index 87b5597f3..80167567f 100644 --- a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs +++ b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs @@ -154,12 +154,12 @@ private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryReq // The default path: full RFC validation plus smuggling hardening. private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into) { - if (!UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, into, Limits, out var bytesRead)) + if (!UltraHardenedParser.TryParseRequestHeader(ref buffer, into, Limits, out var consumed)) { return false; } - buffer = buffer.Slice(bytesRead + 1); + buffer = buffer.Slice(consumed); return true; } diff --git a/Engine/Shared/GenHTTP.Engine.Shared.csproj b/Engine/Shared/GenHTTP.Engine.Shared.csproj index ccdbba897..e4afd6cc9 100644 --- a/Engine/Shared/GenHTTP.Engine.Shared.csproj +++ b/Engine/Shared/GenHTTP.Engine.Shared.csproj @@ -14,7 +14,7 @@ - + diff --git a/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs b/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs index cfc38f8b7..4192901cd 100644 --- a/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs +++ b/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs @@ -453,9 +453,9 @@ public async Task TestContinueNotCarriedOver() var buffer = (await pipe.Reader.ReadAsync()).Buffer; - Assert.IsTrue(UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request.Source, ParserLimits.Default, out var bytesRead)); + Assert.IsTrue(UltraHardenedParser.TryParseRequestHeader(ref buffer, request.Source, ParserLimits.Default, out var consumed)); - request.Apply(Substitute.For(), Substitute.For(), pipe.Reader, buffer.Slice(bytesRead + 1).Start, null, null, output.Writer); + request.Apply(Substitute.For(), Substitute.For(), pipe.Reader, buffer.Slice(consumed).Start, null, null, output.Writer); await request.GetBody()!.AsMemoryAsync(); @@ -520,8 +520,8 @@ public async Task TestWithoutConnection() if (validate) { - Assert.IsTrue(UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request.Source, ParserLimits.Default, out var bytesRead)); - buffer = buffer.Slice(bytesRead + 1); + Assert.IsTrue(UltraHardenedParser.TryParseRequestHeader(ref buffer, request.Source, ParserLimits.Default, out var consumed)); + buffer = buffer.Slice(consumed); } else { From db2a6272ffb3d136a96d746f4483e4fa5e4f58b8 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 16:02:56 +0000 Subject: [PATCH 2/4] Expect 400 for a malformed HTTP version, 505 for an unsupported one Glyph11 0.4.0 (dotnet-web-stack/Glyph11#79) splits what it used to report as one 505: a version that matches HTTP-version ("HTTP/" DIGIT "." DIGIT) but is not 1.0/1.1 stays a 505, "Unsupported HTTP version.", while bytes that are not an HTTP-version at all make the request line invalid, a 400 (RFC 9112 2.3 and 3). "GET / HTTP/1" has no minor version, so it is malformed rather than unsupported, which is also what http-probe's COMP-VERSION-MISSING-MINOR expects. HTTP/2.0 keeps its 505, under the new message. --- Testing/Acceptance/Engine/Compliance/ComplianceTests.cs | 2 +- Testing/Acceptance/Engine/WireTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Testing/Acceptance/Engine/Compliance/ComplianceTests.cs b/Testing/Acceptance/Engine/Compliance/ComplianceTests.cs index d811e26da..a62610a22 100644 --- a/Testing/Acceptance/Engine/Compliance/ComplianceTests.cs +++ b/Testing/Acceptance/Engine/Compliance/ComplianceTests.cs @@ -12,7 +12,7 @@ public class ComplianceTests : WireTest [MultiEngineTest] public async Task TestNoMinorHttpVersion(ServerEngine engine) { - await TestAsync(["GET / HTTP/1", "Host: localhost"], "505", Layout.Create(), engine); + await TestAsync(["GET / HTTP/1", "Host: localhost"], "400", Layout.Create(), engine); } [TestMethod] diff --git a/Testing/Acceptance/Engine/WireTests.cs b/Testing/Acceptance/Engine/WireTests.cs index ae8d29b45..042dd0d26 100644 --- a/Testing/Acceptance/Engine/WireTests.cs +++ b/Testing/Acceptance/Engine/WireTests.cs @@ -18,7 +18,7 @@ public async Task TestNoHost() [TestMethod] public async Task TestUnsupportedProtocolVersion() { - await TestAsync("GET / HTTP/2.0\r\n", "Invalid HTTP version"); + await TestAsync("GET / HTTP/2.0\r\n", "Unsupported HTTP version"); } [TestMethod] From 231978485a36d374a14da73851cdd3b896012db5 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 16:03:03 +0000 Subject: [PATCH 3/4] Expect a truncated chunked body to hand out its data before failing Glyph11 0.4.0 (dotnet-web-stack/Glyph11#83) hands chunk data out as it arrives instead of waiting for the whole chunk, so a peer that declares a huge chunk can no longer make the connection buffer it. For "5\r\nHel" the first read now returns the three bytes that did arrive, and the truncation surfaces as the 400 ProviderException on the next read - the point where the body actually ends early. --- Testing/Acceptance/Engine/Body/ChunkedFramingTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Testing/Acceptance/Engine/Body/ChunkedFramingTests.cs b/Testing/Acceptance/Engine/Body/ChunkedFramingTests.cs index b1542adac..78584a330 100644 --- a/Testing/Acceptance/Engine/Body/ChunkedFramingTests.cs +++ b/Testing/Acceptance/Engine/Body/ChunkedFramingTests.cs @@ -194,7 +194,12 @@ public async Task TestTruncatedBodyFails() { var (_, framing) = await CreateAsync("5\r\nHel"); - var e = await Assert.ThrowsExactlyAsync(async () => await framing.ReadAsync(new byte[16], CancellationToken.None)); + var buffer = new byte[16]; + + Assert.AreEqual(3, await framing.ReadAsync(buffer, CancellationToken.None)); + Assert.AreEqual("Hel", Encoding.ASCII.GetString(buffer, 0, 3)); + + var e = await Assert.ThrowsExactlyAsync(async () => await framing.ReadAsync(buffer, CancellationToken.None)); Assert.AreEqual(ResponseStatus.BadRequest, e.Status); } From 5e0d9fb2ee3da58ff12fefc2553ff969bdeec1b2 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 22:48:19 +0000 Subject: [PATCH 4/4] Drop the Glyph11.Pico dependency Glyph11 discontinues Glyph11.Pico and Glyph11.Native (dotnet-web-stack/Glyph11#84): they get no further releases. The Ioxide engine's GENHTTP_IOXIDE_PARSER=pico switch goes with the package, so requests are always parsed by UltraHardenedParser, and the body lifecycle tests that need unvalidated framing headers get them from Glyph11's FlexibleParser instead of picohttpparser. --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 1 - .../Protocol/Drivers/Tcp/Http1Driver.cs | 24 +------------------ .../Engine/Body/RequestBodyLifecycleTests.cs | 8 +++---- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 4a8f00f79..df36c082a 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -23,7 +23,6 @@ - diff --git a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs index 80167567f..065cc6fc3 100644 --- a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs +++ b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs @@ -10,7 +10,6 @@ using Glyph11; using Glyph11.Parser; using Glyph11.Parser.UltraHardened; -using Glyph11.Pico; using Glyph11.Protocol; using Microsoft.Extensions.Logging; @@ -28,11 +27,6 @@ internal static class Http1Driver { private static readonly ParserLimits Limits = ParserLimits.Default; - // GENHTTP_IOXIDE_PARSER=pico swaps the hardened managed parser for picohttpparser, which - // does no path/token/smuggling hardening. For benchmarking, not for untrusted traffic. - private static readonly bool UsePico = - string.Equals(Environment.GetEnvironmentVariable("GENHTTP_IOXIDE_PARSER"), "pico", StringComparison.OrdinalIgnoreCase); - private static readonly ByteString KeepAliveValue = new("Keep-Alive"); [ThreadStatic] @@ -147,12 +141,8 @@ private static async ValueTask SendErrorAsync(IServer server, PipeWriter writer, } } - // Parses one request head, through whichever parser this process was started with. + // Parses one request head: full RFC validation plus smuggling hardening. private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) - => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into); - - // The default path: full RFC validation plus smuggling hardening. - private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, BinaryRequest into) { if (!UltraHardenedParser.TryParseRequestHeader(ref buffer, into, Limits, out var consumed)) { @@ -163,18 +153,6 @@ private static bool TryParseRequestGlyph11(ref ReadOnlySequence buffer, Bi return true; } - // The benchmark path: picohttpparser, which validates only as far as picohttpparser does. - private static bool TryParseRequestPico(ref ReadOnlySequence buffer, BinaryRequest into) - { - if (!PicoParser.TryParse(buffer, into, out var consumed)) - { - return false; - } - - buffer = buffer.Slice(consumed + 1); - return true; - } - // Runs one request through the handler chain and writes the response; returns whether the connection lives on. private static async ValueTask HandleRequestAsync(IServer server, PipeWriter writer, Request request) { diff --git a/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs b/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs index 4192901cd..26d4efcd6 100644 --- a/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs +++ b/Testing/Acceptance/Engine/Body/RequestBodyLifecycleTests.cs @@ -9,8 +9,8 @@ using GenHTTP.Engine.Shared.Types; using Glyph11.Parser; +using Glyph11.Parser.FlexibleParser; using Glyph11.Parser.UltraHardened; -using Glyph11.Pico; using NSubstitute; @@ -525,9 +525,9 @@ public async Task TestWithoutConnection() } else { - // picohttpparser does not validate the framing headers, so they reach the request as sent - Assert.IsTrue(PicoParser.TryParse(buffer, request.Source, out var consumed)); - buffer = buffer.Slice(consumed + 1); + // FlexibleParser does not validate the framing headers, so they reach the request as sent + Assert.IsTrue(FlexibleParser.TryParseRequestHeader(ref buffer, request.Source, out var consumed)); + buffer = buffer.Slice(consumed); } var endPoint = Substitute.For();