Glyph11
The hardened HTTP/1.1 request parser in pure C#. Dependency-free, AOT/trim-friendly, runs on
any .NET target. Full RFC 9110/9112 validation, configurable limits, and request-smuggling defenses in a
single zero-copy pass — into a reusable BinaryRequest.
Install
dotnet add package Glyph11
Quick start
The primary entry point parses a ReadOnlySequence<byte> — what you get from a
PipeReader or socket, which may be split across several buffers.
using System.Buffers;
using System.Text;
using Glyph11.Protocol; // BinaryRequest
using Glyph11.Parser; // ParserLimits
using Glyph11.Parser.UltraHardened; // UltraHardenedParser
// BinaryRequest is reusable — allocate once, Clear() and reuse per request (see below).
var request = new BinaryRequest();
// ParserLimits is your security policy: header/URL sizes, counts. Default is a sane baseline.
var limits = ParserLimits.Default;
// The bytes to parse. In real code this is the buffer a PipeReader handed you.
ReadOnlySequence<byte> buffer = new(Encoding.ASCII.GetBytes(
"GET /api/users?page=1&sort=asc HTTP/1.1\r\n" +
"Host: example.com\r\n" +
"Accept: */*\r\n\r\n"));
// Returns:
// true → a complete, valid header block was parsed into `request`
// false → the buffer doesn't yet hold a full header block (read more, then retry)
// throws HttpParseException → the bytes are a complete but INVALID/malicious request
if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead))
{
// `request` is now populated. `bytesRead` marks the header block (see "Errors & reuse").
Console.WriteLine(Encoding.ASCII.GetString(request.Method.Span)); // GET
Console.WriteLine(Encoding.ASCII.GetString(request.Path.Span)); // /api/users
}
Reading the result
Every field on BinaryRequest is a ReadOnlyMemory<byte> slice of your input
buffer — zero-copy. Decode to text only when you actually need a string.
// --- the request line ---
ReadOnlyMemory<byte> method = request.Method; // "GET"
ReadOnlyMemory<byte> path = request.Path; // "/api/users" (query stripped off)
ReadOnlyMemory<byte> version = request.Version; // "HTTP/1.1"
// --- headers: a list of (name, value) byte-slice pairs, in order ---
for (int i = 0; i < request.Headers.Count; i++)
{
// Each entry is a KeyValuePair<ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>.
var (name, value) = request.Headers[i];
Console.WriteLine($"{Encoding.ASCII.GetString(name.Span)}: {Encoding.ASCII.GetString(value.Span)}");
// Host: example.com
// Accept: */*
}
// --- query parameters: parsed out of the request target ---
for (int i = 0; i < request.QueryParameters.Count; i++)
{
var (key, val) = request.QueryParameters[i];
Console.WriteLine($"{Encoding.ASCII.GetString(key.Span)} = {Encoding.ASCII.GetString(val.Span)}");
// page = 1
// sort = asc
}
// Find a header by name without allocating strings — HTTP names compare case-insensitively (ASCII):
static bool HeaderIs(ReadOnlyMemory<byte> name, ReadOnlySpan<byte> want)
=> System.Text.Ascii.EqualsIgnoreCase(name.Span, want);
ReadOnlyMemory<byte> host = default;
for (int i = 0; i < request.Headers.Count; i++)
if (HeaderIs(request.Headers[i].Key, "Host"u8)) { host = request.Headers[i].Value; break; }
Contiguous fast path
If your request is already in one contiguous buffer (a single byte[] /
ReadOnlyMemory<byte>), use TryExtractFullHeaderROM — it skips the
segment-walking machinery and parses in place.
// A request you already have fully buffered as one block.
ReadOnlyMemory<byte> rom = Encoding.ASCII.GetBytes(
"GET / HTTP/1.1\r\nHost: x\r\n\r\n");
// Same contract as above (true / false / throws), but for a single contiguous buffer.
if (UltraHardenedParser.TryExtractFullHeaderROM(ref rom, request, in limits, out int read))
{
// request.Method, request.Path, ...
}
TryExtractFullHeaderValidated already takes the contiguous fast
path internally when its sequence is single-segment — so prefer it unless you specifically hold a
ReadOnlyMemory<byte> and want to skip the sequence wrapper.Limits & configuration
ParserLimits is a readonly record struct — derive a custom policy with a
with expression. A request that exceeds a limit is rejected (mapped to HTTP 431), never
allowed to overflow.
// Start from the defaults and tighten/loosen specific knobs.
var limits = ParserLimits.Default with
{
MaxHeaderCount = 64, // reject requests with more than 64 headers
MaxHeaderValueLength = 4 * 1024, // 4 KB max per header value
MaxUrlLength = 2 * 1024, // 2 KB max request target
MaxTotalHeaderBytes = 16 * 1024, // 16 KB cap on the whole header block
};
// Available knobs (all int): MaxHeaderCount, MaxHeaderNameLength, MaxHeaderValueLength,
// MaxUrlLength, MaxQueryParameterCount, MaxMethodLength, MaxTotalHeaderBytes.
Reading from a PipeReader
The real-world loop: read, try to parse, and if the buffer doesn't yet hold a full header block, ask for more bytes and retry.
async Task HandleAsync(PipeReader reader, ParserLimits limits, CancellationToken ct)
{
var request = new BinaryRequest();
while (true)
{
ReadResult read = await reader.ReadAsync(ct);
ReadOnlySequence<byte> buffer = read.Buffer;
try
{
request.Clear(); // reset before each parse attempt
if (UltraHardenedParser.TryExtractFullHeaderValidated(ref buffer, request, in limits, out int bytesRead))
{
// Got a full, valid request. The body starts right after the header block.
// `bytesRead` follows glyph11's -1 convention, so advance by bytesRead + 1:
SequencePosition bodyStart = read.Buffer.GetPosition(bytesRead + 1);
// ... dispatch `request`, then read/handle the body from bodyStart ...
reader.AdvanceTo(bodyStart);
return;
}
// Not enough bytes yet: mark everything examined so the next ReadAsync waits for more.
reader.AdvanceTo(buffer.Start, buffer.End);
if (read.IsCompleted) break; // connection closed mid-request
}
catch (HttpParseException)
{
// A complete but invalid/malicious request — respond 400/431 and close.
break;
}
}
}
Chunked bodies
After the headers, a 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.
using Glyph11.Parser;
var decoder = new ChunkedBodyStream();
int offset = 0;
var body = new ArrayBufferWriter<byte>();
while (true)
{
// Try to read the next chunk from the remaining input.
ChunkResult r = decoder.TryReadChunk(
input.AsSpan(offset),
out int consumed, // bytes of input this call used (framing + payload)
out int dataOffset, // where the decoded payload starts, relative to the slice
out int dataLength); // how many payload bytes
switch (r)
{
case ChunkResult.Chunk: // a payload chunk is ready
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
break;
case ChunkResult.Error: // malformed chunk framing → reject the request
throw new InvalidOperationException("bad chunked encoding");
}
break;
}
// There is also a TryReadChunk(in ReadOnlySequence<byte>, out long, out ReadOnlySequence<byte>)
// overload that decodes fragmented input without linearizing.
Errors & reuse
true— a complete, valid header block was parsed.false— the buffer is incomplete; read more bytes and call again.HttpParseException— the bytes are a complete but invalid request (bad token, smuggling vector, limit breach, …). Map to HTTP 400 / 431 and close the connection.
bytesRead is the header length minus one — a long-standing glyph11
convention. The body begins at bytesRead + 1; advance your reader by that.Reuse the request to stay allocation-free across a connection: request.Clear() between
parses keeps the pooled header/query arrays; request.Dispose() returns them to the
ArrayPool when you're done with the connection.
var request = new BinaryRequest();
try
{
// per request:
request.Clear();
// ... parse + handle ...
}
finally
{
request.Dispose(); // returns pooled arrays; not disposing won't leak, just skips the pooling
}