From eb899b49243a3cc8402b919e7ad3e74e82416a60 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 16:05:05 +0000 Subject: [PATCH] Linearize only the header block of multi-segment input The ReadOnlySequence entry points (UltraHardenedParser requests and responses, FlexibleParser) found the CRLFCRLF and then copied the whole sequence with ToArray() - any pipelined body included - before MaxTotalHeaderBytes was checked, which only happened inside the ROM parse. A tiny header followed by a huge pipelined body therefore forced an allocation the size of the body. The reader that located the terminator already knows the header block's length (reader.Consumed). The hardened entry points now reject a block over MaxTotalHeaderBytes with the same 431 before copying anything, and all three copy input.Slice(0, reader.Consumed) instead of the whole sequence. The ROM parse never reads past the terminator, so the parsed fields and bytesReadCount are unchanged. Fixes #65. --- .../FlexibleParser.TryExtractFullHeader.cs | 9 +- ...ltraHardenedParser.TryExtractFullHeader.cs | 13 +- ...enedParser.TryExtractFullResponseHeader.cs | 13 +- tests/Tests/MultiSegment.HeaderCopy.cs | 252 ++++++++++++++++++ 4 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 tests/Tests/MultiSegment.HeaderCopy.cs diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs index 5142c9b..f3943e0 100644 --- a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs +++ b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs @@ -9,8 +9,8 @@ public static partial class FlexibleParser /// Entry point: tries to extract a complete HTTP/1.1 header block (request line + headers). /// /// Single-segment input is dispatched to the zero-copy ROM path. - /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized - /// via ToArray() and parsed through the ROM path. + /// Multi-segment input is checked for completeness (\r\n\r\n), then its header block + /// alone is linearized (a pipelined body is never copied) and parsed through the ROM path. /// /// /// Input buffer from the network layer. @@ -33,8 +33,9 @@ public static bool TryExtractFullHeader(ref ReadOnlySequence input, Binary return false; } - // Linearize: copy all segments into a single contiguous array, then parse via ROM - ReadOnlyMemory mem = input.ToArray(); + // Linearize the header block only — never the pipelined body behind it. + // reader.Consumed is the block's length, CRLFCRLF included. + ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray(); return TryExtractFullHeaderReadOnlyMemory(ref mem, request, out bytesReadCount); } } diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs index 68801d8..ccede79 100644 --- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs @@ -9,8 +9,9 @@ public static partial class UltraHardenedParser /// Entry point: combined parse + semantic validation with full security checks. /// /// Single-segment input is dispatched to the zero-copy validated ROM path. - /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized - /// via ToArray() and parsed through the validated ROM path. + /// Multi-segment input is checked for completeness (\r\n\r\n) and against + /// , then its header block alone is linearized + /// (a pipelined body is never copied) and parsed through the validated ROM path. /// /// /// Input buffer from the network layer. @@ -37,8 +38,12 @@ public static bool TryExtractFullHeaderValidated( return false; } - // Linearize: copy all segments into a single contiguous array, then parse via ROM - ReadOnlyMemory mem = input.ToArray(); + // Linearize the header block only — never the pipelined body behind it — and only once it + // is known to fit: reader.Consumed is the block's length, CRLFCRLF included. + if (reader.Consumed > limits.MaxTotalHeaderBytes) + throw new HttpParseException("Total header size exceeds limit.", statusCode: 431); + + ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray(); return TryExtractFullHeaderROM(ref mem, request, in limits, out bytesReadCount); } } diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs index 3ead9ce..e5d45c7 100644 --- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs @@ -10,8 +10,9 @@ public static partial class UltraHardenedParser /// checks. /// /// Single-segment input is dispatched to the zero-copy validated ROM path. - /// Multi-segment input is checked for completeness (\r\n\r\n), then linearized - /// via ToArray() and parsed through the validated ROM path. + /// Multi-segment input is checked for completeness (\r\n\r\n) and against + /// , then its header block alone is linearized + /// (the body is never copied) and parsed through the validated ROM path. /// /// /// Input buffer from the network layer. @@ -43,8 +44,12 @@ public static bool TryExtractFullResponseHeaderValidated( return false; } - // Linearize: copy all segments into a single contiguous array, then parse via ROM - ReadOnlyMemory mem = input.ToArray(); + // Linearize the header block only — never the body behind it — and only once it is known + // to fit: reader.Consumed is the block's length, CRLFCRLF included. + if (reader.Consumed > limits.MaxTotalHeaderBytes) + throw new HttpParseException("Total header size exceeds limit.", statusCode: 431); + + ReadOnlyMemory mem = input.Slice(0, reader.Consumed).ToArray(); return TryExtractFullResponseHeaderROM(ref mem, response, in limits, out bytesReadCount); } } diff --git a/tests/Tests/MultiSegment.HeaderCopy.cs b/tests/Tests/MultiSegment.HeaderCopy.cs new file mode 100644 index 0000000..a434670 --- /dev/null +++ b/tests/Tests/MultiSegment.HeaderCopy.cs @@ -0,0 +1,252 @@ +using System.Buffers; +using System.Runtime.InteropServices; +using System.Text; +using Glyph11; +using Glyph11.Parser; +using Glyph11.Parser.FlexibleParser; +using Glyph11.Parser.UltraHardened; +using Glyph11.Protocol; +using Glyph11.Utils; + +namespace Tests; + +/// +/// Multi-segment input is linearized before parsing. Only the header block may be copied — a +/// pipelined body behind it never — and the header limit is checked before anything is copied +/// (issue #65). +/// +public class MultiSegmentHeaderCopyTests : IDisposable +{ + private const int BodySize = 8 * 1024 * 1024; + + private const string RequestHeader = + "POST /upload?x=1 HTTP/1.1\r\nHost: example.com\r\nContent-Length: 8388608\r\n\r\n"; + + private const string ResponseHeader = + "HTTP/1.1 200 OK\r\nContent-Length: 8388608\r\n\r\n"; + + private readonly BinaryRequest _request = new(); + private readonly BinaryResponse _response = new(); + + public void Dispose() + { + _request.Dispose(); + _response.Dispose(); + GC.SuppressFinalize(this); + } + + // ================================================================ + // UltraHardenedParser — requests + // ================================================================ + + [Fact] + public void Request_PipelinedBody_IsNotCopied() + { + var limits = ParserLimits.Default; + var seq = HeaderThenBody(RequestHeader, BodySize); + + WarmUp(() => + { + var warm = HeaderThenBody(RequestHeader, 16); + UltraHardenedParser.TryExtractFullHeaderValidated(ref warm, _request, in limits, out _); + }); + _request.Clear(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + bool ok = UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(ok); + Assert.Equal(RequestHeader.Length - 1, bytesRead); + AssertAscii.Equal("POST", _request.Method); + AssertAscii.Equal("/upload", _request.Path); + Assert.Equal(2, _request.Headers.Count); + AssertAscii.Equal("Content-Length", _request.Headers[1].Key); + + Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method)); + Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte pipelined body"); + } + + [Theory] + [InlineData(1)] // terminator split as "\r" | "\n\r\n" + [InlineData(2)] // "\r\n" | "\r\n" + [InlineData(3)] // "\r\n\r" | "\n" + public void Request_TerminatorSplitAcrossSegments_CopiesExactlyTheHeaderBlock(int splitInsideTerminator) + { + var limits = ParserLimits.Default; + int cut = RequestHeader.Length - 4 + splitInsideTerminator; + var bytes = Encoding.ASCII.GetBytes(RequestHeader + "BODYBODYBODY"); + var seq = Segments(bytes[..cut], bytes[cut..]); + + Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead)); + Assert.Equal(RequestHeader.Length - 1, bytesRead); + Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Path)); + } + + [Fact] + public void Request_BodyStartingInTheTerminatorsSegment_IsNotCopied() + { + var limits = ParserLimits.Default; + var head = Encoding.ASCII.GetBytes(RequestHeader); + var seq = Segments(head[..20], [.. head[20..], .. "partial body"u8.ToArray()], new byte[4096]); + + Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead)); + Assert.Equal(RequestHeader.Length - 1, bytesRead); + Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method)); + } + + [Fact] + public void Request_HeaderOverLimit_Throws431_BeforeCopying() + { + var limits = ParserLimits.Default with { MaxTotalHeaderBytes = RequestHeader.Length - 1 }; + var seq = HeaderThenBody(RequestHeader, BodySize); + + WarmUp(() => + { + var warm = HeaderThenBody(RequestHeader, 16); + try { UltraHardenedParser.TryExtractFullHeaderValidated(ref warm, _request, in limits, out _); } + catch (HttpParseException) { } + }); + + long before = GC.GetAllocatedBytesForCurrentThread(); + var ex = Assert.Throws( + () => UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out _)); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(431, ex.StatusCode); + Assert.True(allocated < 1024 * 1024, $"allocated {allocated} bytes before rejecting"); + } + + [Fact] + public void Request_HeaderExactlyAtLimit_Parses() + { + var limits = ParserLimits.Default with { MaxTotalHeaderBytes = RequestHeader.Length }; + var seq = HeaderThenBody(RequestHeader, 1024); + + Assert.True(UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out int bytesRead)); + Assert.Equal(RequestHeader.Length - 1, bytesRead); + } + + // ================================================================ + // UltraHardenedParser — responses + // ================================================================ + + [Fact] + public void Response_Body_IsNotCopied() + { + var limits = ParserLimits.Default; + var seq = HeaderThenBody(ResponseHeader, BodySize); + + WarmUp(() => + { + var warm = HeaderThenBody(ResponseHeader, 16); + UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref warm, _response, in limits, out _); + }); + _response.Clear(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + bool ok = UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, _response, in limits, out int bytesRead); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(ok); + Assert.Equal(ResponseHeader.Length - 1, bytesRead); + Assert.Equal(200, _response.Status); + AssertAscii.Equal("OK", _response.ReasonPhrase); + + Assert.Equal(ResponseHeader.Length, BackingArrayLength(_response.Version)); + Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte body"); + } + + [Fact] + public void Response_HeaderOverLimit_Throws431_BeforeCopying() + { + var limits = ParserLimits.Default with { MaxTotalHeaderBytes = ResponseHeader.Length - 1 }; + var seq = HeaderThenBody(ResponseHeader, BodySize); + + WarmUp(() => + { + var warm = HeaderThenBody(ResponseHeader, 16); + try { UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref warm, _response, in limits, out _); } + catch (HttpParseException) { } + }); + + long before = GC.GetAllocatedBytesForCurrentThread(); + var ex = Assert.Throws( + () => UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, _response, in limits, out _)); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(431, ex.StatusCode); + Assert.True(allocated < 1024 * 1024, $"allocated {allocated} bytes before rejecting"); + } + + // ================================================================ + // FlexibleParser + // ================================================================ + + [Fact] + public void Flexible_PipelinedBody_IsNotCopied() + { + var seq = HeaderThenBody(RequestHeader, BodySize); + + WarmUp(() => + { + var warm = HeaderThenBody(RequestHeader, 16); + FlexibleParser.TryExtractFullHeader(ref warm, _request, out _); + }); + _request.Clear(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + bool ok = FlexibleParser.TryExtractFullHeader(ref seq, _request, out int bytesRead); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(ok); + Assert.Equal(RequestHeader.Length - 1, bytesRead); + AssertAscii.Equal("/upload", _request.Path); + Assert.Equal(2, _request.Headers.Count); + + Assert.Equal(RequestHeader.Length, BackingArrayLength(_request.Method)); + Assert.True(allocated < 64 * 1024, $"allocated {allocated} bytes for an {BodySize}-byte pipelined body"); + } + + // ================================================================ + // Helpers + // ================================================================ + + private static void WarmUp(Action action) + { + // First calls JIT and initialize statics; keep that out of the allocation measurements. + action(); + action(); + } + + private static int BackingArrayLength(ReadOnlyMemory slice) + { + Assert.True(MemoryMarshal.TryGetArray(slice, out ArraySegment segment)); + return segment.Array!.Length; + } + + /// + /// The header split across two segments, then body bytes in + /// 1 MiB segments. + /// + private static ReadOnlySequence HeaderThenBody(string header, int bodySize) + { + var head = Encoding.ASCII.GetBytes(header); + var parts = new List { head[..(head.Length / 2)], head[(head.Length / 2)..] }; + + for (int remaining = bodySize; remaining > 0; remaining -= 1024 * 1024) + parts.Add(new byte[Math.Min(remaining, 1024 * 1024)]); + + return Segments([.. parts]); + } + + private static ReadOnlySequence Segments(params byte[][] parts) + { + var first = new BufferSegment(parts[0]); + var last = first; + for (int i = 1; i < parts.Length; i++) + last = last.Append(parts[i]); + + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } +}