From a2e47ca7dae54f53dc8994b2909b6b42e1c5d04f Mon Sep 17 00:00:00 2001
From: Diogo Martins The real-world loop: read, try to parse, and if the buffer doesn't yet hold a full header block,
@@ -186,7 +187,9 @@ After the headers, a Limits & configuration
};
// Available knobs (all int): MaxHeaderCount, MaxHeaderNameLength, MaxHeaderValueLength,
-// MaxUrlLength, MaxQueryParameterCount, MaxMethodLength, MaxTotalHeaderBytes.
+// MaxUrlLength, MaxQueryParameterCount, MaxMethodLength, MaxTotalHeaderBytes,
+// MaxTotalTrailerBytes (chunked trailer section; pass limits to new ChunkedBodyStream(limits)).
Reading from a PipeReader
Reading from a PipeReader
Chunked bodies
Transfer-Encoding: chunked body is decoded with
ChunkedBodyStream — a small struct state machine you feed bytes to. It tells you where each
- decoded chunk's payload sits, without copying.ParserLimits.MaxTotalTrailerBytes.
using Glyph11.Parser;
var decoder = new ChunkedBodyStream();
@@ -204,13 +207,13 @@ Chunked bodies
switch (r)
{
- case ChunkResult.Chunk: // a payload chunk is ready
+ case ChunkResult.Chunk: // payload bytes are ready: a whole chunk or the part that arrived
body.Write(input.AsSpan(offset + dataOffset, dataLength));
offset += consumed;
continue;
case ChunkResult.Completed: // terminal 0-length chunk seen → body done
break;
- case ChunkResult.NeedMoreData: // partial chunk — read more bytes, then call again
+ case ChunkResult.NeedMoreData: // incomplete framing — read more bytes, then call again
break;
case ChunkResult.Error: // malformed chunk framing → reject the request
throw new InvalidOperationException("bad chunked encoding");
diff --git a/src/Glyph11/Parser/ChunkedBodyStream.cs b/src/Glyph11/Parser/ChunkedBodyStream.cs
index 162d691..b772521 100644
--- a/src/Glyph11/Parser/ChunkedBodyStream.cs
+++ b/src/Glyph11/Parser/ChunkedBodyStream.cs
@@ -10,7 +10,10 @@ public enum ChunkResult : byte
/// Incomplete data — wait for more bytes from the pipe.
NeedMoreData,
- /// A complete chunk's decoded payload is available.
+ ///
+ /// Decoded payload bytes are available: a whole chunk, or the part of one that has arrived so
+ /// far (never empty). Chunk boundaries carry no meaning, so append the bytes and call again.
+ ///
Chunk,
/// Terminal chunk (and any trailers) consumed — body is done.
@@ -21,6 +24,11 @@ public enum ChunkResult : byte
/// Stateful streaming parser for chunked transfer-encoding (RFC 9112 §7.1).
/// Validates chunks incrementally so the caller can advance a PipeReader
/// after each call without re-parsing from byte 0.
+///
+/// Chunk data is handed out as it arrives rather than once the whole chunk is buffered, so the
+/// caller's buffer is bounded by its read window, not by the size a peer declares. The trailer
+/// section is capped by .
+///
///
public struct ChunkedBodyStream
{
@@ -42,6 +50,22 @@ private enum Phase : byte
private long _remaining; // bytes left in ChunkData phase
private int _digitCount; // hex digits seen in ChunkSize
private int _extensionBytes; // extension bytes (4096 limit)
+ private int _maxTrailerBytes; // trailer section cap, when constructed with limits
+ private bool _hasLimits; // false for new ChunkedBodyStream(): ParserLimits.Default applies
+
+ ///
+ /// Creates a decoder that enforces (today:
+ /// ). new ChunkedBodyStream() applies
+ /// .
+ ///
+ public ChunkedBodyStream(in ParserLimits limits) : this()
+ {
+ _maxTrailerBytes = limits.MaxTotalTrailerBytes;
+ _hasLimits = true;
+ }
+
+ private readonly int MaxTrailerBytes
+ => _hasLimits ? _maxTrailerBytes : ParserLimits.Default.MaxTotalTrailerBytes;
///
/// Reads the next chunk from .
@@ -52,11 +76,16 @@ private enum Phase : byte
/// Offset within where chunk payload starts (valid when result is ).
/// Length of chunk payload bytes (valid when result is ).
///
- /// : payload at input[dataOffset..dataOffset+dataLength], advance by bytesConsumed.
+ /// : payload at input[dataOffset..dataOffset+dataLength] — a whole chunk or the
+ /// part of one that has arrived, never empty — advance by bytesConsumed.
/// : terminal chunk consumed, body is done.
- /// : incomplete, wait for more from pipe.
+ /// : incomplete, nothing consumed — wait for more from pipe and
+ /// re-present the same bytes.
///
- /// Thrown on malformed chunked encoding.
+ ///
+ /// Thrown on malformed chunked encoding, or (status 431) when the trailer section exceeds
+ /// .
+ ///
public ChunkResult TryReadChunk(
ReadOnlySpan input,
out int bytesConsumed,
@@ -67,6 +96,7 @@ public ChunkResult TryReadChunk(
dataOffset = 0;
dataLength = 0;
int pos = 0;
+ int trailerStart = 0; // where the trailer section begins; it is always parsed within one call
// Snapshot state so we can roll back on NeedMoreData.
// The caller re-presents the same bytes, so partial state must not persist.
@@ -169,6 +199,7 @@ public ChunkResult TryReadChunk(
{
// Terminal chunk — move to trailers
_phase = Phase.Trailers;
+ trailerStart = pos;
continue;
}
@@ -180,16 +211,22 @@ public ChunkResult TryReadChunk(
case Phase.ChunkData:
{
- int available = input.Length - pos;
+ int available = input.Length - pos; // >= 1: the loop only runs while input remains
if (available < _remaining)
{
- this = snapshot;
- return ChunkResult.NeedMoreData;
+ // Hand out what has arrived instead of waiting for the whole chunk: the
+ // caller's buffer is then bounded by its read window, not by whatever size
+ // the peer declared. The rest of the chunk follows on later calls.
+ dataOffset = pos;
+ dataLength = available;
+ _remaining -= available;
+ bytesConsumed = input.Length;
+ return ChunkResult.Chunk;
}
- // All chunk data is present
+ // The rest of the chunk's data is present
dataOffset = pos;
- dataLength = (int)_chunkSize;
+ dataLength = (int)_remaining;
pos += (int)_remaining;
_remaining = 0;
_phase = Phase.DataCrlf;
@@ -200,6 +237,14 @@ public ChunkResult TryReadChunk(
{
if (pos + 1 >= input.Length)
{
+ // The CRLF after the data has not arrived. Data read by this call is
+ // handed out anyway — the CRLF is checked on the next call.
+ if (dataLength > 0)
+ {
+ bytesConsumed = pos;
+ return ChunkResult.Chunk;
+ }
+
this = snapshot;
return ChunkResult.NeedMoreData;
}
@@ -218,8 +263,14 @@ public ChunkResult TryReadChunk(
_digitCount = 0;
_extensionBytes = 0;
- bytesConsumed = pos;
- return ChunkResult.Chunk;
+ if (dataLength > 0)
+ {
+ bytesConsumed = pos;
+ return ChunkResult.Chunk;
+ }
+
+ // The data was handed out by an earlier call: go on to the next chunk.
+ continue;
}
case Phase.Trailers:
@@ -254,6 +305,16 @@ public ChunkResult TryReadChunk(
pos++;
}
+ // The trailer section is re-presented from its start until it completes, so
+ // cap the bytes seen so far — finished or not — or an endless section is
+ // buffered forever. A CR that ends the input may be half of the final CRLF,
+ // so it is not counted yet.
+ int seen = pos - trailerStart;
+ if (pos >= input.Length && input[pos - 1] == (byte)'\r')
+ seen--;
+ if (seen > MaxTrailerBytes)
+ throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431);
+
if (pos >= input.Length)
{
this = snapshot;
@@ -271,7 +332,14 @@ public ChunkResult TryReadChunk(
}
}
- // Ran out of input
+ // Ran out of input. A chunk whose data is all here but whose CRLF is not is still
+ // handed out (the CRLF is checked on the next call); anything else waits.
+ if (dataLength > 0)
+ {
+ bytesConsumed = pos;
+ return ChunkResult.Chunk;
+ }
+
this = snapshot;
return ChunkResult.NeedMoreData;
}
@@ -280,9 +348,14 @@ public ChunkResult TryReadChunk(
/// Multi-segment overload of .
/// Reads the next chunk directly from a — no linearization.
/// On , is the decoded payload as a
- /// (possibly multi-segment) slice of ; copy it out before advancing the
- /// reader past .
+ /// (possibly multi-segment) slice of — a whole chunk or the part of one
+ /// that has arrived, never empty; copy it out before advancing the reader past
+ /// .
///
+ ///
+ /// Thrown on malformed chunked encoding, or (status 431) when the trailer section exceeds
+ /// .
+ ///
public ChunkResult TryReadChunk(
in ReadOnlySequence input,
out long bytesConsumed,
@@ -294,6 +367,7 @@ public ChunkResult TryReadChunk(
var reader = new SequenceReader(input);
var snapshot = this; // roll back on NeedMoreData — caller re-presents the bytes
ReadOnlySequence pending = default;
+ long trailerStart = 0; // the trailer section is always parsed within one call
while (!reader.End)
{
@@ -374,6 +448,7 @@ public ChunkResult TryReadChunk(
if (_chunkSize == 0)
{
_phase = Phase.Trailers;
+ trailerStart = reader.Consumed;
continue;
}
_remaining = _chunkSize;
@@ -385,8 +460,14 @@ public ChunkResult TryReadChunk(
{
if (reader.Remaining < _remaining)
{
- this = snapshot;
- return ChunkResult.NeedMoreData;
+ // Hand out what has arrived (at least one byte: the loop runs only while
+ // input remains) instead of buffering the whole declared chunk.
+ long available = reader.Remaining;
+ chunkData = reader.UnreadSequence;
+ reader.Advance(available);
+ _remaining -= available;
+ bytesConsumed = reader.Consumed;
+ return ChunkResult.Chunk;
}
pending = reader.UnreadSequence.Slice(0, _remaining);
reader.Advance(_remaining);
@@ -399,6 +480,15 @@ public ChunkResult TryReadChunk(
{
if (reader.Remaining < 2)
{
+ // The CRLF after the data has not arrived: hand out data read by this call
+ // anyway — the CRLF is checked on the next call.
+ if (!pending.IsEmpty)
+ {
+ bytesConsumed = reader.Consumed;
+ chunkData = pending;
+ return ChunkResult.Chunk;
+ }
+
this = snapshot;
return ChunkResult.NeedMoreData;
}
@@ -413,9 +503,15 @@ public ChunkResult TryReadChunk(
_digitCount = 0;
_extensionBytes = 0;
- bytesConsumed = reader.Consumed;
- chunkData = pending;
- return ChunkResult.Chunk;
+ if (!pending.IsEmpty)
+ {
+ bytesConsumed = reader.Consumed;
+ chunkData = pending;
+ return ChunkResult.Chunk;
+ }
+
+ // The data was handed out by an earlier call: go on to the next chunk.
+ continue;
}
case Phase.Trailers:
@@ -435,11 +531,23 @@ public ChunkResult TryReadChunk(
// a trailer line, up to CRLF; a bare LF inside it is invalid
if (!reader.TryReadTo(out ReadOnlySequence line, ParserConstants.Crlf, advancePastDelimiter: true))
{
+ // Re-presented from the start of the section until it completes: cap the
+ // bytes seen so far, or an endless trailer line is buffered forever. A CR
+ // that ends the input may be half of the final CRLF: not counted yet.
+ var unread = reader.UnreadSequence;
+ long seen = reader.Consumed - trailerStart + unread.Length;
+ if (unread.Slice(unread.Length - 1).FirstSpan[0] == (byte)'\r')
+ seen--;
+ if (seen > MaxTrailerBytes)
+ throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431);
+
this = snapshot;
return ChunkResult.NeedMoreData;
}
if (line.PositionOf((byte)'\n') is not null)
throw new HttpParseException("Bare LF in chunked trailer.");
+ if (reader.Consumed - trailerStart > MaxTrailerBytes)
+ throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431);
continue;
}
@@ -451,6 +559,15 @@ public ChunkResult TryReadChunk(
}
}
+ // Ran out of input. A chunk whose data is all here but whose CRLF is not is still
+ // handed out (the CRLF is checked on the next call); anything else waits.
+ if (!pending.IsEmpty)
+ {
+ bytesConsumed = reader.Consumed;
+ chunkData = pending;
+ return ChunkResult.Chunk;
+ }
+
this = snapshot;
return ChunkResult.NeedMoreData;
}
diff --git a/src/Glyph11/Parser/ParserLimits.cs b/src/Glyph11/Parser/ParserLimits.cs
index f118c1d..211c231 100644
--- a/src/Glyph11/Parser/ParserLimits.cs
+++ b/src/Glyph11/Parser/ParserLimits.cs
@@ -36,6 +36,14 @@ public readonly record struct ParserLimits
/// Maximum total size of the header block including request line and terminators (default 1048576).
public int MaxTotalHeaderBytes { get; init; }
+ ///
+ /// Maximum total size of a chunked body's trailer section in bytes: every trailer field line
+ /// after the last chunk, including its CRLF (default 16384). Enforced by
+ /// and ; exceeding
+ /// it is a 431, like an oversized header block.
+ ///
+ public int MaxTotalTrailerBytes { get; init; }
+
/// Returns a with safe production defaults.
public static ParserLimits Default => new()
{
@@ -46,6 +54,7 @@ public readonly record struct ParserLimits
MaxQueryParameterCount = 128,
MaxMethodLength = 16,
MaxReasonPhraseLength = 512,
- MaxTotalHeaderBytes = 1_048_576
+ MaxTotalHeaderBytes = 1_048_576,
+ MaxTotalTrailerBytes = 16_384
};
}
diff --git a/src/Glyph11/Validation/ChunkedBodyValidator.cs b/src/Glyph11/Validation/ChunkedBodyValidator.cs
index b17bd06..723f9c1 100644
--- a/src/Glyph11/Validation/ChunkedBodyValidator.cs
+++ b/src/Glyph11/Validation/ChunkedBodyValidator.cs
@@ -1,3 +1,4 @@
+using Glyph11.Parser;
namespace Glyph11.Validation;
@@ -9,12 +10,23 @@ public static class ChunkedBodyValidator
private const int MaxChunkExtensionBytes = 4096;
///
- /// Validates chunked body format.
+ /// Validates chunked body format under .
/// Returns true if valid and complete.
/// Returns false if incomplete (need more data).
/// Throws if malformed.
///
public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed)
+ => TryValidate(body, ParserLimits.Default, out bytesConsumed);
+
+ ///
+ /// Validates chunked body format, enforcing (today:
+ /// ).
+ /// Returns true if valid and complete.
+ /// Returns false if incomplete (need more data).
+ /// Throws if malformed, or with status 431 when the trailer
+ /// section exceeds the limit.
+ ///
+ public static bool TryValidate(ReadOnlySpan body, in ParserLimits limits, out int bytesConsumed)
{
bytesConsumed = 0;
int pos = 0;
@@ -110,6 +122,8 @@ public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed)
// --- Last chunk (size == 0): parse optional trailers ---
if (chunkSize == 0)
{
+ int trailerStart = pos;
+
// Trailers: zero or more header lines, terminated by CRLF
while (true)
{
@@ -145,6 +159,15 @@ public static bool TryValidate(ReadOnlySpan body, out int bytesConsumed)
pos++;
}
+ // Cap the trailer section — the bytes seen so far, finished or not, so an
+ // endless one is refused rather than waited on. A CR that ends the input may
+ // be half of the final CRLF, so it is not counted yet.
+ int seen = pos - trailerStart;
+ if (!foundCrlf && body[pos - 1] == (byte)'\r')
+ seen--;
+ if (seen > limits.MaxTotalTrailerBytes)
+ throw new HttpParseException("Chunked trailer section exceeds limit.", statusCode: 431);
+
if (!foundCrlf)
return false; // incomplete trailer line
}
diff --git a/tests/Tests/ChunkedBodyStream.Limits.cs b/tests/Tests/ChunkedBodyStream.Limits.cs
new file mode 100644
index 0000000..e73a7c3
--- /dev/null
+++ b/tests/Tests/ChunkedBodyStream.Limits.cs
@@ -0,0 +1,529 @@
+using System.Buffers;
+using System.Text;
+using Glyph11;
+using Glyph11.Parser;
+using Glyph11.Utils;
+using Glyph11.Validation;
+
+namespace Tests;
+
+///
+/// Resource ceilings of chunked decoding: chunk data is handed out as it arrives (so buffering is
+/// bounded by the read window, not by the declared chunk size), and the trailer section is capped
+/// by .
+///
+public class ChunkedBodyStreamLimitsTests
+{
+ private const int DefaultTrailerCap = 16_384;
+
+ private static byte[] B(string s) => Encoding.ASCII.GetBytes(s);
+
+ private static ReadOnlySequence Segmented(byte[] data, int segments)
+ {
+ if (segments <= 1 || data.Length <= 1)
+ return new ReadOnlySequence(data);
+
+ int size = Math.Max(1, data.Length / segments);
+ BufferSegment? first = null, last = null;
+ for (int pos = 0; pos < data.Length; pos += size)
+ {
+ var mem = data.AsMemory(pos, Math.Min(size, data.Length - pos));
+ if (first is null) { first = new BufferSegment(mem); last = first; }
+ else last = last!.Append(mem);
+ }
+ return new ReadOnlySequence(first!, 0, last!, last!.Memory.Length);
+ }
+
+ // ---- drivers: behave like a PipeReader loop — only unconsumed bytes stay buffered ----
+
+ private sealed class Feed
+ {
+ public readonly List Body = new();
+ public int MaxBuffered;
+ public int Fed;
+ public int Leftover;
+ }
+
+ private static Feed FeedSpan(ChunkedBodyStream stream, byte[] input, Func readSize)
+ {
+ var f = new Feed();
+ var buffer = new List();
+ while (true)
+ {
+ if (buffer.Count > 0)
+ {
+ var span = buffer.ToArray();
+ var r = stream.TryReadChunk(span, out var consumed, out var off, out var len);
+ if (r == ChunkResult.Chunk)
+ {
+ Assert.True(len > 0, "a Chunk result must carry data");
+ f.Body.AddRange(span.AsSpan(off, len).ToArray());
+ buffer.RemoveRange(0, consumed);
+ continue;
+ }
+ if (r == ChunkResult.Completed)
+ {
+ buffer.RemoveRange(0, consumed);
+ f.Leftover = buffer.Count + (input.Length - f.Fed);
+ return f;
+ }
+ Assert.Equal(0, consumed); // NeedMoreData consumes nothing
+ }
+
+ if (f.Fed == input.Length)
+ throw new Xunit.Sdk.XunitException("input ended before the body completed");
+
+ int n = Math.Min(readSize(), input.Length - f.Fed);
+ buffer.AddRange(input.AsSpan(f.Fed, n).ToArray());
+ f.Fed += n;
+ f.MaxBuffered = Math.Max(f.MaxBuffered, buffer.Count);
+ }
+ }
+
+ private static Feed FeedSequence(ChunkedBodyStream stream, byte[] input, Func readSize, int segments = 3)
+ {
+ var f = new Feed();
+ var buffer = new List();
+ while (true)
+ {
+ if (buffer.Count > 0)
+ {
+ var seq = Segmented(buffer.ToArray(), segments);
+ var r = stream.TryReadChunk(seq, out var consumed, out var data);
+ if (r == ChunkResult.Chunk)
+ {
+ Assert.True(data.Length > 0, "a Chunk result must carry data");
+ f.Body.AddRange(data.ToArray());
+ buffer.RemoveRange(0, (int)consumed);
+ continue;
+ }
+ if (r == ChunkResult.Completed)
+ {
+ buffer.RemoveRange(0, (int)consumed);
+ f.Leftover = buffer.Count + (input.Length - f.Fed);
+ return f;
+ }
+ Assert.Equal(0, consumed);
+ }
+
+ if (f.Fed == input.Length)
+ throw new Xunit.Sdk.XunitException("input ended before the body completed");
+
+ int n = Math.Min(readSize(), input.Length - f.Fed);
+ buffer.AddRange(input.AsSpan(f.Fed, n).ToArray());
+ f.Fed += n;
+ f.MaxBuffered = Math.Max(f.MaxBuffered, buffer.Count);
+ }
+ }
+
+ private static Func Fixed(int n) => () => n;
+
+ private static Func RandomReads(int seed, int max)
+ {
+ var rng = new Random(seed);
+ return () => rng.Next(1, max + 1);
+ }
+
+ /// Encodes as chunks; returns the wire bytes and the decoded body.
+ private static (byte[] wire, byte[] body) Chunked(IEnumerable pieces, string trailers = "")
+ {
+ var wire = new StringBuilder();
+ var body = new StringBuilder();
+ foreach (var p in pieces)
+ {
+ wire.Append(p.Length.ToString("x")).Append("\r\n").Append(p).Append("\r\n");
+ body.Append(p);
+ }
+ wire.Append("0\r\n").Append(trailers).Append("\r\n");
+ return (B(wire.ToString()), B(body.ToString()));
+ }
+
+ public static IEnumerable