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 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 0000000..20c01b6 Binary files /dev/null and b/tests/Tests/UltraHardenedParser.Response.cs differ