diff --git a/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/FlexibleParser/FlexibleParser.TryExtractFullHeader.cs index c599e14..18eb1ce 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. @@ -36,8 +36,9 @@ public static bool TryParseRequestHeader(ref ReadOnlySequence input, Binar 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 TryParseRequestHeader(ref mem, request, out consumed); } } diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullHeader.cs index ed6fe80..e89a868 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. @@ -40,8 +41,12 @@ public static bool TryParseRequestHeader( 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 TryParseRequestHeader(ref mem, request, in limits, out consumed); } } diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs index 9416cec..6bd1ea8 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. @@ -47,8 +48,12 @@ public static bool TryParseResponseHeader( 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 TryParseResponseHeader(ref mem, response, in limits, out consumed); } } 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); + } +}