Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Glyph11/Glyph11.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
<Description>Dependency free, low allocation HTTP/1.1 parser for C#.</Description>
<PackageTags>C# HTTP Protocol Parser</PackageTags>

<AssemblyVersion>0.3.6.0</AssemblyVersion>
<FileVersion>0.3.6.0</FileVersion>
<Version>0.3.6</Version>
<AssemblyVersion>0.3.7.0</AssemblyVersion>
<FileVersion>0.3.7.0</FileVersion>
<Version>0.3.7</Version>

<AllowUnsafeBlocks>true</AllowUnsafeBlocks>

Expand Down
7 changes: 7 additions & 0 deletions src/Glyph11/Parser/ParserLimits.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public readonly record struct ParserLimits
/// <summary>Maximum length of the HTTP method token in bytes (default 16).</summary>
public int MaxMethodLength { get; init; }

/// <summary>
/// Maximum length of a response's reason phrase in bytes (default 512). Responses only; a
/// request has no equivalent field.
/// </summary>
public int MaxReasonPhraseLength { get; init; }

/// <summary>Maximum total size of the header block including request line and terminators (default 1048576).</summary>
public int MaxTotalHeaderBytes { get; init; }

Expand All @@ -39,6 +45,7 @@ public readonly record struct ParserLimits
MaxUrlLength = 8192,
MaxQueryParameterCount = 128,
MaxMethodLength = 16,
MaxReasonPhraseLength = 512,
MaxTotalHeaderBytes = 1_048_576
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
using System.Runtime.CompilerServices;
using Glyph11.Protocol;

namespace Glyph11.Parser.UltraHardened;

public static partial class UltraHardenedParser
{
/// <summary>
/// Combined parse + semantic validation of a RESPONSE header — single-segment hot path.
/// <para>
/// Returns <c>false</c> if incomplete; throws <see cref="HttpParseException"/> if
/// structurally or semantically invalid.
/// </para>
/// </summary>
/// <remarks>
/// The header block is parsed by the same rules as a request's — field-lines are identical in
/// both directions, so obs-fold, bare LF, whitespace before the colon and the
/// Transfer-Encoding/Content-Length smuggling checks all carry over unchanged.
/// <para>
/// What does not carry over: there is no Host rule (that is a request requirement), no
/// request-target, and the first line is <c>HTTP-version SP status-code SP [reason-phrase]</c>
/// rather than <c>METHOD SP target SP HTTP-version</c>.
/// </para>
/// <para>
/// Whether a body follows at all is not decidable from the response alone — HEAD and 304 are
/// framed by the request that produced them. See
/// <see cref="Validation.BodyFramingDetector.DetectResponseBodyFraming"/>.
/// </para>
/// </remarks>
[SkipLocalsInit]
public static bool TryExtractFullResponseHeaderROM(
ref ReadOnlyMemory<byte> input, BinaryResponse response,
in ParserLimits limits, out int bytesReadCount)
{
bytesReadCount = -1;
var span = input.Span;

int headerEnd = span.IndexOf(ParserConstants.CrlfCrlf);
if (headerEnd < 0) return false;

int totalHeaderBytes = headerEnd + 4;
if (totalHeaderBytes > limits.MaxTotalHeaderBytes)
throw new HttpParseException("Total header size exceeds limit.", statusCode: 431);

// ---- Status line: HTTP-version SP status-code SP [ reason-phrase ] CRLF — RFC 9112 §4 ----

int statusLineEnd = span.IndexOf(ParserConstants.Crlf);
if (statusLineEnd < 0)
throw new HttpParseException("Invalid HTTP/1.1 status line.");

var statusLine = span[..statusLineEnd];

// ---- Reject bare LF in status line — RFC 9112 §2.2 ----
if (statusLine.IndexOf((byte)'\n') >= 0)
throw new HttpParseException("Bare LF detected; only CRLF line endings are allowed.");

int firstSpace = statusLine.IndexOf(ParserConstants.Space);
if (firstSpace < 0)
throw new HttpParseException("Invalid status line: missing status code.");

// --- Version ---
var versionSpan = statusLine[..firstSpace];
if (!ParserConstants.IsValidHttpVersion(versionSpan))
throw new HttpParseException("Invalid HTTP version.", 505);

response.Version = input[..firstSpace];

// --- Status code: exactly three digits — RFC 9112 §4 ---
int codeStart = firstSpace + 1;
if (codeStart + 3 > statusLine.Length)
throw new HttpParseException("Invalid status line: truncated status code.");

var codeSpan = statusLine.Slice(codeStart, 3);
if (!ParserConstants.IsDigit(codeSpan[0]) ||
!ParserConstants.IsDigit(codeSpan[1]) ||
!ParserConstants.IsDigit(codeSpan[2]))
throw new HttpParseException("Status code must be exactly three digits.");

int status = ((codeSpan[0] - '0') * 100) + ((codeSpan[1] - '0') * 10) + (codeSpan[2] - '0');

// A three-digit code below 100 is well-formed but not a status — 0xx has no meaning and
// treating it as one lets a garbage first line pass as a response.
if (status < 100)
throw new HttpParseException("Status code must be in the range 100-599.");

response.StatusCode = input.Slice(codeStart, 3);
response.Status = status;

// --- Reason phrase (optional) ---
int afterCode = codeStart + 3;
if (afterCode == statusLine.Length)
{
// "HTTP/1.1 200" with no trailing space. The grammar asks for the SP even when the
// phrase is empty, but origins do send this and it is not a parsing ambiguity, so it
// is accepted rather than turned into an interop failure.
response.ReasonPhrase = default;
}
else
{
if (statusLine[afterCode] != ParserConstants.Space)
throw new HttpParseException("Invalid status line: status code must be three digits.");

int reasonStart = afterCode + 1;
int reasonLen = statusLine.Length - reasonStart;

if (reasonLen > limits.MaxReasonPhraseLength)
throw new HttpParseException("Reason phrase length exceeds limit.", statusCode: 431);

// reason-phrase = 1*( HTAB / SP / VCHAR / obs-text ) — the same character set a
// field-value admits, which is what makes this check reusable. It is a charset check
// and nothing more: the phrase is free text and carries no meaning to act on.
var reasonSpan = statusLine.Slice(reasonStart, reasonLen);
if (!ParserConstants.IsValidFieldValue(reasonSpan))
throw new HttpParseException("Reason phrase contains invalid characters.");

response.ReasonPhrase = input.Slice(reasonStart, reasonLen);
}

// ---- Headers (structural parse + inline semantic checks) ----

int lineStart = statusLineEnd + 2;
int headerCount = 0;

// Semantic state tracked across headers
bool hasCL = false;
bool hasTE = false;
ReadOnlySpan<byte> firstCLValue = default;

while (true)
{
int lineLen = span[lineStart..].IndexOf(ParserConstants.Crlf);
if (lineLen < 0)
throw new HttpParseException("Invalid headers.");

if (lineLen == 0)
break;

var line = span.Slice(lineStart, lineLen);

// ---- Reject bare LF in header line — RFC 9112 §2.2 ----
if (line.IndexOf((byte)'\n') >= 0)
throw new HttpParseException("Bare LF detected; only CRLF line endings are allowed.");

// ---- Reject obs-fold (line starting with SP/HTAB) — RFC 9112 §5.2 ----
if (line[0] == (byte)' ' || line[0] == (byte)'\t')
throw new HttpParseException("Obsolete line folding (obs-fold) is not allowed.");

int colon = line.IndexOf(ParserConstants.Colon);

if (colon <= 0)
throw new HttpParseException(colon == 0
? "Header name is empty."
: "Malformed header line: missing colon.");

// ---- Reject whitespace between field-name and colon — RFC 9112 §5.1 ----
if (line[colon - 1] == (byte)' ' || line[colon - 1] == (byte)'\t')
throw new HttpParseException("Whitespace between header name and colon is not allowed.");

// Validate header name
var nameSpan = line[..colon];
if (nameSpan.Length > limits.MaxHeaderNameLength)
throw new HttpParseException("Header name length exceeds limit.", statusCode: 431);
if (!ParserConstants.IsValidToken(nameSpan))
throw new HttpParseException("Header name contains invalid token characters.");

// Trim leading OWS from value
int valAbsStart = lineStart + colon + 1;
while (valAbsStart < lineStart + lineLen)
{
byte b = span[valAbsStart];
if (b != (byte)' ' && b != (byte)'\t') break;
valAbsStart++;
}

int valLen = (lineStart + lineLen) - valAbsStart;

// Validate header value
var valueSpan = span.Slice(valAbsStart, valLen);
if (valLen > limits.MaxHeaderValueLength)
throw new HttpParseException("Header value length exceeds limit.", statusCode: 431);
if (!ParserConstants.IsValidFieldValue(valueSpan))
throw new HttpParseException("Header value contains invalid characters.");

if (++headerCount > limits.MaxHeaderCount)
throw new HttpParseException("Header count exceeds limit.", statusCode: 431);

response.Headers.Add(
input.Slice(lineStart, colon),
input.Slice(valAbsStart, valLen));

// ---- Inline semantic checks keyed by header name ----
// Length pre-check avoids the full case-insensitive compare for most headers.

if (nameSpan.Length == 14 && ParserConstants.AsciiEqualsIgnoreCase(nameSpan, ContentLengthName))
{
// RFC 9110 §8.6 — validate format (syntax, leading zeros, overflow)
if (!SemIsValidContentLengthValue(valueSpan))
throw new HttpParseException("Invalid Content-Length format.");

// RFC 9112 §6.2 — comma-separated values must all be identical
if (SemHasConflictingCommaSeparatedCL(valueSpan))
throw new HttpParseException("Conflicting comma-separated Content-Length values.");

// RFC 9110 §8.6 — multiple CL headers must have identical values
if (hasCL)
{
if (!valueSpan.SequenceEqual(firstCLValue))
throw new HttpParseException("Conflicting Content-Length headers.");
}
else
{
firstCLValue = valueSpan;
hasCL = true;
}
}
else if (nameSpan.Length == 17 && ParserConstants.AsciiEqualsIgnoreCase(nameSpan, ParserConstants.TransferEncodingName))
{
hasTE = true;

// RFC 9112 §6.1 — only "chunked" is accepted
var trimmed = SemTrimOWS(valueSpan);
if (!ParserConstants.AsciiEqualsIgnoreCase(trimmed, ParserConstants.ChunkedValue))
throw new HttpParseException("Invalid Transfer-Encoding value; only 'chunked' is accepted.");
}

lineStart += lineLen + 2;
}

// ---- Post-loop cross-header semantic checks ----

// RFC 9112 §6.1 — TE + CL together is a desync vector in this direction too: a proxy that
// frames one way and a client that frames the other disagree on where the response ends,
// and the remainder becomes the head of the next one.
if (hasTE && hasCL)
throw new HttpParseException("Both Transfer-Encoding and Content-Length are present.");

bytesReadCount += totalHeaderBytes;
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Buffers;
using Glyph11.Protocol;

namespace Glyph11.Parser.UltraHardened;

public static partial class UltraHardenedParser
{
/// <summary>
/// Entry point for RESPONSE headers: combined parse + semantic validation with full security
/// checks.
/// <para>
/// Single-segment input is dispatched to the zero-copy validated ROM path.
/// Multi-segment input is checked for completeness (<c>\r\n\r\n</c>), then linearized
/// via <c>ToArray()</c> and parsed through the validated ROM path.
/// </para>
/// </summary>
/// <param name="input">Input buffer from the network layer.</param>
/// <param name="response">Target to populate with parsed response data.</param>
/// <param name="limits">Resource limits to enforce during parsing.</param>
/// <param name="bytesReadCount">Bytes consumed on success, or -1 if incomplete.</param>
/// <returns><c>true</c> if a complete header was parsed and validated; <c>false</c> if more data is needed.</returns>
/// <exception cref="HttpParseException">Thrown on any protocol or semantic violation.</exception>
/// <remarks>
/// A client that already holds the response in one contiguous buffer should call
/// <see cref="TryExtractFullResponseHeaderROM"/> directly and skip the linearizing copy — a
/// response header spanning segments is the common case on a socket, not the rare one.
/// </remarks>
public static bool TryExtractFullResponseHeaderValidated(
ref ReadOnlySequence<byte> input, BinaryResponse response,
in ParserLimits limits, out int bytesReadCount)
{
if (input.IsSingleSegment)
{
ReadOnlyMemory<byte> singleMemorySegment = input.First;
return TryExtractFullResponseHeaderROM(ref singleMemorySegment, response, in limits, out bytesReadCount);
}

// Check for header completeness before allocating
var reader = new SequenceReader<byte>(input);
if (!reader.TryReadTo(out ReadOnlySequence<byte> _, ParserConstants.CrlfCrlf, advancePastDelimiter: true))
{
bytesReadCount = -1;
return false;
}

// Linearize: copy all segments into a single contiguous array, then parse via ROM
ReadOnlyMemory<byte> mem = input.ToArray();
return TryExtractFullResponseHeaderROM(ref mem, response, in limits, out bytesReadCount);
}
}
63 changes: 63 additions & 0 deletions src/Glyph11/Protocol/BinaryResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace Glyph11.Protocol;

/// <summary>
/// Holds the parsed components of an HTTP/1.1 response header.
/// All byte-level fields are <see cref="ReadOnlyMemory{T}"/> slices that reference
/// the original input buffer (zero-copy on the single-segment path).
/// <para>
/// Reuse instances across responses by calling <see cref="Clear"/> between parses.
/// Call <see cref="Dispose"/> when the instance is no longer needed to return
/// pooled arrays used by <see cref="Headers"/>.
/// </para>
/// </summary>
public sealed class BinaryResponse : IDisposable
{
private readonly KeyValueList _headers = new();

/// <summary>HTTP version string, e.g. "HTTP/1.1". Set by UltraHardenedParser only.</summary>
public ReadOnlyMemory<byte> Version { get; internal set; }

/// <summary>
/// Status code as the three bytes that arrived, e.g. "404". <see cref="Status"/> is the same
/// value already parsed, and is what callers normally want.
/// </summary>
public ReadOnlyMemory<byte> StatusCode { get; internal set; }

/// <summary>Status code as an integer, 100-599.</summary>
public int Status { get; internal set; }

/// <summary>
/// Reason phrase, e.g. "Not Found". Empty is legal and common: HTTP/2 and HTTP/3 have no
/// reason phrase at all, so anything translating from them emits none.
/// </summary>
public ReadOnlyMemory<byte> ReasonPhrase { get; internal set; }

/// <summary>Parsed HTTP headers as key-value pairs.</summary>
public KeyValueList Headers => _headers;

/// <summary>Response body bytes. Not populated by the header parser.</summary>
public ReadOnlyMemory<byte> Body { get; internal set; }

/// <summary>
/// Resets the response for reuse. Clears headers but keeps the underlying pooled
/// arrays allocated.
/// </summary>
public void Clear()
{
Version = default;
StatusCode = default;
Status = 0;
ReasonPhrase = default;
Body = default;
_headers.Clear();
}

/// <summary>
/// Returns pooled arrays to <see cref="System.Buffers.ArrayPool{T}"/>.
/// The instance should not be used after disposal.
/// </summary>
public void Dispose()
{
_headers.Dispose();
}
}
Loading
Loading