pure managed

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, ...
}
Tip: 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
}