From e9f2cfa000fd3fdf78cb795be2e94207b8271a11 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Wed, 12 Aug 2026 00:49:07 +0100 Subject: [PATCH 1/2] feat: parse HTTP/1.1 responses, not just requests Glyph11 hardens the request direction only, which leaves the client half of HTTP/1.1 unserved: a client writes requests and reads responses, so none of the existing entry points apply to it. Adds BinaryResponse and UltraHardenedParser.TryExtractFullResponseHeader{ROM, Validated}, mirroring the request parser. The header block reuses its rules unchanged - bare LF, obs-fold, whitespace before the colon, token and field-value validation, the Content-Length format and duplicate checks, and the Transfer-Encoding + Content-Length rejection, which is a desync vector in this direction too. What does not carry over: - No Host rule. That is a request requirement; applying it would reject every response. - The first line is HTTP-version SP status-code SP [reason-phrase]. The status code must be exactly three digits and at least 100; the reason phrase is optional, may be empty, and is charset-checked but never interpreted. - "HTTP/1.1 200\r\n" with no trailing space is accepted. The grammar asks for the SP even when the phrase is empty, but origins send this and it creates no ambiguity, so rejecting it would only cost interop. BodyFramingDetector.DetectResponseBodyFraming takes the REQUEST METHOD, and has to: a HEAD response carries the Content-Length its body would have had and no body, so framing a response from its own headers alone reads the next response as this one's content. 1xx, 204 and 304 are bodyless whatever the headers say, a 2xx to CONNECT is a tunnel rather than a body, and a response with no framing header at all runs until the connection closes - the new BodyFraming.UntilClose, which has no request equivalent. ParserLimits gains MaxReasonPhraseLength (default 512). 37 new tests, 403 green overall. --- src/Glyph11/Parser/ParserLimits.cs | 7 + .../UltraHardenedParser.FullResponse.ROM.cs | 240 ++++++++++++++++++ ...enedParser.TryExtractFullResponseHeader.cs | 50 ++++ src/Glyph11/Protocol/BinaryResponse.cs | 63 +++++ src/Glyph11/Validation/BodyFraming.cs | 10 + src/Glyph11/Validation/BodyFramingDetector.cs | 77 ++++++ tests/Tests/UltraHardenedParser.Response.cs | Bin 0 -> 9847 bytes 7 files changed, 447 insertions(+) create mode 100644 src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs create mode 100644 src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs create mode 100644 src/Glyph11/Protocol/BinaryResponse.cs create mode 100644 tests/Tests/UltraHardenedParser.Response.cs diff --git a/src/Glyph11/Parser/ParserLimits.cs b/src/Glyph11/Parser/ParserLimits.cs index 556c4a0..f118c1d 100644 --- a/src/Glyph11/Parser/ParserLimits.cs +++ b/src/Glyph11/Parser/ParserLimits.cs @@ -27,6 +27,12 @@ public readonly record struct ParserLimits /// Maximum length of the HTTP method token in bytes (default 16). public int MaxMethodLength { get; init; } + /// + /// Maximum length of a response's reason phrase in bytes (default 512). Responses only; a + /// request has no equivalent field. + /// + public int MaxReasonPhraseLength { get; init; } + /// Maximum total size of the header block including request line and terminators (default 1048576). public int MaxTotalHeaderBytes { get; init; } @@ -39,6 +45,7 @@ public readonly record struct ParserLimits MaxUrlLength = 8192, MaxQueryParameterCount = 128, MaxMethodLength = 16, + MaxReasonPhraseLength = 512, MaxTotalHeaderBytes = 1_048_576 }; } diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs new file mode 100644 index 0000000..078e681 --- /dev/null +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs @@ -0,0 +1,240 @@ +using System.Runtime.CompilerServices; +using Glyph11.Protocol; + +namespace Glyph11.Parser.UltraHardened; + +public static partial class UltraHardenedParser +{ + /// + /// Combined parse + semantic validation of a RESPONSE header — single-segment hot path. + /// + /// Returns false if incomplete; throws if + /// structurally or semantically invalid. + /// + /// + /// + /// 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. + /// + /// What does not carry over: there is no Host rule (that is a request requirement), no + /// request-target, and the first line is HTTP-version SP status-code SP [reason-phrase] + /// rather than METHOD SP target SP HTTP-version. + /// + /// + /// 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 + /// . + /// + /// + [SkipLocalsInit] + public static bool TryExtractFullResponseHeaderROM( + ref ReadOnlyMemory 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 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; + } +} diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs new file mode 100644 index 0000000..3ead9ce --- /dev/null +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.TryExtractFullResponseHeader.cs @@ -0,0 +1,50 @@ +using System.Buffers; +using Glyph11.Protocol; + +namespace Glyph11.Parser.UltraHardened; + +public static partial class UltraHardenedParser +{ + /// + /// Entry point for RESPONSE headers: 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. + /// + /// + /// Input buffer from the network layer. + /// Target to populate with parsed response data. + /// Resource limits to enforce during parsing. + /// Bytes consumed on success, or -1 if incomplete. + /// true if a complete header was parsed and validated; false if more data is needed. + /// Thrown on any protocol or semantic violation. + /// + /// A client that already holds the response in one contiguous buffer should call + /// directly and skip the linearizing copy — a + /// response header spanning segments is the common case on a socket, not the rare one. + /// + public static bool TryExtractFullResponseHeaderValidated( + ref ReadOnlySequence input, BinaryResponse response, + in ParserLimits limits, out int bytesReadCount) + { + if (input.IsSingleSegment) + { + ReadOnlyMemory singleMemorySegment = input.First; + return TryExtractFullResponseHeaderROM(ref singleMemorySegment, response, in limits, out bytesReadCount); + } + + // Check for header completeness before allocating + var reader = new SequenceReader(input); + if (!reader.TryReadTo(out ReadOnlySequence _, ParserConstants.CrlfCrlf, advancePastDelimiter: true)) + { + bytesReadCount = -1; + return false; + } + + // Linearize: copy all segments into a single contiguous array, then parse via ROM + ReadOnlyMemory mem = input.ToArray(); + return TryExtractFullResponseHeaderROM(ref mem, response, in limits, out bytesReadCount); + } +} diff --git a/src/Glyph11/Protocol/BinaryResponse.cs b/src/Glyph11/Protocol/BinaryResponse.cs new file mode 100644 index 0000000..a943dc9 --- /dev/null +++ b/src/Glyph11/Protocol/BinaryResponse.cs @@ -0,0 +1,63 @@ +namespace Glyph11.Protocol; + +/// +/// Holds the parsed components of an HTTP/1.1 response header. +/// All byte-level fields are slices that reference +/// the original input buffer (zero-copy on the single-segment path). +/// +/// Reuse instances across responses by calling between parses. +/// Call when the instance is no longer needed to return +/// pooled arrays used by . +/// +/// +public sealed class BinaryResponse : IDisposable +{ + private readonly KeyValueList _headers = new(); + + /// HTTP version string, e.g. "HTTP/1.1". Set by UltraHardenedParser only. + public ReadOnlyMemory Version { get; internal set; } + + /// + /// Status code as the three bytes that arrived, e.g. "404". is the same + /// value already parsed, and is what callers normally want. + /// + public ReadOnlyMemory StatusCode { get; internal set; } + + /// Status code as an integer, 100-599. + public int Status { get; internal set; } + + /// + /// 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. + /// + public ReadOnlyMemory ReasonPhrase { get; internal set; } + + /// Parsed HTTP headers as key-value pairs. + public KeyValueList Headers => _headers; + + /// Response body bytes. Not populated by the header parser. + public ReadOnlyMemory Body { get; internal set; } + + /// + /// Resets the response for reuse. Clears headers but keeps the underlying pooled + /// arrays allocated. + /// + public void Clear() + { + Version = default; + StatusCode = default; + Status = 0; + ReasonPhrase = default; + Body = default; + _headers.Clear(); + } + + /// + /// Returns pooled arrays to . + /// The instance should not be used after disposal. + /// + public void Dispose() + { + _headers.Dispose(); + } +} diff --git a/src/Glyph11/Validation/BodyFraming.cs b/src/Glyph11/Validation/BodyFraming.cs index 8d5246b..92859f9 100644 --- a/src/Glyph11/Validation/BodyFraming.cs +++ b/src/Glyph11/Validation/BodyFraming.cs @@ -15,6 +15,13 @@ public enum BodyFraming : byte /// Chunked transfer-encoding — use . Chunked, + + /// + /// Responses only: the body runs until the peer closes the connection (RFC 9112 §6.3). A + /// request can never be framed this way — a client that closed its send side to delimit a body + /// would have no way left to read the answer. + /// + UntilClose, } /// @@ -46,4 +53,7 @@ private BodyFramingResult(BodyFraming framing, long contentLength) /// Chunked transfer-encoding. public static BodyFramingResult ForChunked => new(BodyFraming.Chunked, 0); + + /// Body delimited by connection close — responses only. + public static BodyFramingResult ForUntilClose => new(BodyFraming.UntilClose, 0); } diff --git a/src/Glyph11/Validation/BodyFramingDetector.cs b/src/Glyph11/Validation/BodyFramingDetector.cs index 79dd85f..4f26e0d 100644 --- a/src/Glyph11/Validation/BodyFramingDetector.cs +++ b/src/Glyph11/Validation/BodyFramingDetector.cs @@ -12,6 +12,8 @@ public static class BodyFramingDetector private static ReadOnlySpan TransferEncodingName => "transfer-encoding"u8; private static ReadOnlySpan ContentLengthName => "content-length"u8; private static ReadOnlySpan ChunkedValue => "chunked"u8; + private static ReadOnlySpan HeadMethodName => "head"u8; + private static ReadOnlySpan ConnectMethodName => "connect"u8; /// /// Inspects the parsed headers in and returns the body @@ -54,6 +56,81 @@ public static BodyFramingResult DetectBodyFraming(BinaryRequest request) return BodyFramingResult.NoBody; } + /// + /// Inspects the parsed headers in and returns the body framing + /// kind. Single pass over headers. + /// + /// The parsed response header. + /// + /// The method of the request that produced this response. It is REQUIRED, and not a + /// convenience: a response to HEAD carries the Content-Length the body would have had and no + /// body, so framing a response by its own headers alone reads the next response as this one's + /// content. Pass the method exactly as sent. + /// + /// + /// Precedence follows RFC 9112 §6: the status and method decide whether a body can exist at + /// all, then Transfer-Encoding, then Content-Length, and a response with none of those runs + /// until the connection closes. + /// + public static BodyFramingResult DetectResponseBodyFraming( + BinaryResponse response, ReadOnlySpan requestMethod) + { + int status = response.Status; + + // RFC 9112 §6.3 — these carry no body whatever the headers claim. + if (status is >= 100 and < 200 || status == 204 || status == 304) + return BodyFramingResult.NoBody; + + // A HEAD response is a GET response with the body removed: the framing headers describe a + // body that is not there. + if (ParserConstants.AsciiEqualsIgnoreCase(requestMethod, HeadMethodName)) + return BodyFramingResult.NoBody; + + // A 2xx to CONNECT means the tunnel is open and everything after the header is opaque + // relay traffic, not an HTTP body. + if (status is >= 200 and < 300 && + ParserConstants.AsciiEqualsIgnoreCase(requestMethod, ConnectMethodName)) + return BodyFramingResult.NoBody; + + var headers = response.Headers; + ReadOnlySpan contentLengthValue = default; + bool hasChunkedTE = false; + + for (int i = 0; i < headers.Count; i++) + { + var name = headers[i].Key.Span; + + if (name.Length == 17 && ParserConstants.AsciiEqualsIgnoreCase(name, TransferEncodingName)) + { + var value = TrimOws(headers[i].Value.Span); + if (ParserConstants.AsciiEqualsIgnoreCase(value, ChunkedValue)) + hasChunkedTE = true; + } + else if (name.Length == 14 && ParserConstants.AsciiEqualsIgnoreCase(name, ContentLengthName)) + { + contentLengthValue = TrimOws(headers[i].Value.Span); + } + } + + // Chunked takes priority over Content-Length (RFC 9112 §6.1) + if (hasChunkedTE) + return BodyFramingResult.ForChunked; + + if (!contentLengthValue.IsEmpty) + { + long cl = ParseContentLengthDigits(contentLengthValue); + if (cl > 0) + return BodyFramingResult.ForContentLength(cl); + if (cl == 0) + return BodyFramingResult.NoBody; + } + + // No framing header at all. Unlike a request - which is simply bodyless here - a response + // runs to end of connection (RFC 9112 §6.3), which is also the only framing HTTP/1.0 + // origins ever had. + return BodyFramingResult.ForUntilClose; + } + private static ReadOnlySpan TrimOws(ReadOnlySpan value) { int start = 0; diff --git a/tests/Tests/UltraHardenedParser.Response.cs b/tests/Tests/UltraHardenedParser.Response.cs new file mode 100644 index 0000000000000000000000000000000000000000..20c01b6482da74c99c71f423b3008038ce593f5e GIT binary patch literal 9847 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 fXvy#vEmHt}Sh03_t5?E Date: Wed, 12 Aug 2026 01:06:59 +0100 Subject: [PATCH 2/2] chore: 0.3.7 --- src/Glyph11/Glyph11.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Glyph11/Glyph11.csproj b/src/Glyph11/Glyph11.csproj index 7702467..06acc97 100644 --- a/src/Glyph11/Glyph11.csproj +++ b/src/Glyph11/Glyph11.csproj @@ -7,9 +7,9 @@ Dependency free, low allocation HTTP/1.1 parser for C#. C# HTTP Protocol Parser - 0.3.6.0 - 0.3.6.0 - 0.3.6 + 0.3.7.0 + 0.3.7.0 + 0.3.7 true