From 859105ab45ae2c2bb0700da81e07ce34e24ee869 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 2 Nov 2026 15:48:59 +0000 Subject: [PATCH] Reject malformed HTTP versions with 400, unsupported ones with 505 Since #55 every version the parser does not accept throws 505, whether it is HTTP/2.0 or "http/1.1" or "HTTP/1.x". 505 (RFC 9110 15.6.6) says "I understood the version you asked for and do not implement it"; for bytes that are not an HTTP-version at all that is the wrong answer - the request line is simply malformed, which is a 400. Split the two on the request line and, symmetrically, on the status line: a version that matches the grammar ("HTTP/" DIGIT "." DIGIT, exactly 8 case-sensitive bytes, RFC 9112 2.3) but is not 1.0/1.1 is 505 "Unsupported HTTP version."; anything else in that position is 400 "Invalid HTTP version.". The grammar check is an internal helper next to IsValidHttpVersion, so the public surface does not grow. HttpParseException.IsLimitViolation was `StatusCode != 400`, so since #55 it has reported every version error as a limit breach. It now means exactly 431, and the StatusCode doc lists the three codes in use. Fixes #71. --- SECURITY.md | 4 +- src/Glyph11/HttpParseException.cs | 9 +- src/Glyph11/Parser/ParserConstants.cs | 24 ++- .../UltraHardenedParser.FullHeader.ROM.cs | 6 +- .../UltraHardenedParser.FullResponse.ROM.cs | 6 +- .../UltraHardenedParser.VersionStatus.cs | 190 ++++++++++++++++++ 6 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 tests/Tests/UltraHardenedParser.VersionStatus.cs diff --git a/SECURITY.md b/SECURITY.md index 0e49834..05c9354 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -104,9 +104,9 @@ Transfer-Encoding: chunked\r\n **Attack:** Invalid version strings enable protocol downgrade attacks and undefined behavior. -**RFC:** 9112 Section 2.6 — `HTTP-version = "HTTP/" DIGIT "." DIGIT` +**RFC:** 9112 Section 2.3 — `HTTP-version = "HTTP/" DIGIT "." DIGIT` -**Protection:** The version string must be exactly 8 bytes matching `HTTP/X.Y` where X and Y are ASCII digits. +**Protection:** Only `HTTP/1.0` and `HTTP/1.1` are accepted, as exactly 8 case-sensitive bytes. A well-formed version the parser does not implement (`HTTP/2.0`, `HTTP/1.2`, `HTTP/0.9`, …) is rejected with 505 (RFC 9110 Section 15.6.6); anything that does not match the grammar at all (`http/1.1`, `HTTP/1.x`, `HTTP/11`, …) is a malformed message and rejected with 400. ### Resource Limits (DoS Prevention) diff --git a/src/Glyph11/HttpParseException.cs b/src/Glyph11/HttpParseException.cs index d327f36..b84e1ba 100644 --- a/src/Glyph11/HttpParseException.cs +++ b/src/Glyph11/HttpParseException.cs @@ -12,9 +12,12 @@ public HttpParseException(string message, int statusCode = 400) : base(message) StatusCode = statusCode; } - /// The HTTP status code to return (400 for structural errors, 431 for limit breaches). + /// + /// The HTTP status code to return: 400 for structural or semantic errors, 431 for limit + /// breaches, 505 for a well-formed HTTP version the parser does not implement. + /// public int StatusCode { get; } - /// True when the error is a size/count limit breach (→ 431), false for structural errors (→ 400). - public bool IsLimitViolation => StatusCode != 400; + /// True when the error is a size/count limit breach (→ 431); false otherwise (400, 505). + public bool IsLimitViolation => StatusCode == 431; } diff --git a/src/Glyph11/Parser/ParserConstants.cs b/src/Glyph11/Parser/ParserConstants.cs index 0a26492..9767479 100644 --- a/src/Glyph11/Parser/ParserConstants.cs +++ b/src/Glyph11/Parser/ParserConstants.cs @@ -63,8 +63,9 @@ public static bool IsValidFieldValue(ReadOnlySpan span) => span.IndexOfAnyExcept(FieldValueSearchValues) < 0; /// - /// HTTP-version = "HTTP/" DIGIT "." DIGIT (RFC 9112 §2.6) - /// Exactly 8 bytes, case-sensitive. + /// HTTP-version = "HTTP/" DIGIT "." DIGIT (RFC 9112 §2.3) + /// Exactly 8 bytes, case-sensitive — and one of the two versions this parser implements, + /// HTTP/1.0 or HTTP/1.1. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsValidHttpVersion(ReadOnlySpan span) @@ -80,6 +81,25 @@ public static bool IsValidHttpVersion(ReadOnlySpan span) && (span[7] == (byte)'0' || span[7] == (byte)'1'); } + /// + /// True if matches the HTTP-version grammar + /// ("HTTP/" DIGIT "." DIGIT, exactly 8 bytes, case-sensitive — RFC 9112 §2.3), whatever + /// the digits. Separates a version the parser does not implement (505) from bytes that are not + /// an HTTP-version at all (400). + /// + internal static bool IsWellFormedHttpVersion(ReadOnlySpan span) + { + return span.Length == 8 + && span[0] == (byte)'H' + && span[1] == (byte)'T' + && span[2] == (byte)'T' + && span[3] == (byte)'P' + && span[4] == (byte)'/' + && IsDigit(span[5]) + && span[6] == (byte)'.' + && IsDigit(span[7]); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsDigit(byte b) => (uint)(b - '0') <= 9; diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs index 228bc69..0ad5271 100644 --- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullHeader.ROM.cs @@ -94,9 +94,13 @@ public static bool TryExtractFullHeaderROM( throw new HttpParseException("Request-target contains invalid characters."); // --- Version --- + // A well-formed HTTP-version this parser does not implement is 505 (RFC 9110 §15.6.6); + // bytes that are not an HTTP-version at all make a malformed request line, 400. var versionSpan = requestLine[(secondSpace + 1)..]; if (!ParserConstants.IsValidHttpVersion(versionSpan)) - throw new HttpParseException("Invalid HTTP version.", 505); + throw ParserConstants.IsWellFormedHttpVersion(versionSpan) + ? new HttpParseException("Unsupported HTTP version.", 505) + : new HttpParseException("Invalid HTTP version."); request.Version = input.Slice(secondSpace + 1, versionSpan.Length); diff --git a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs index 078e681..97ed7ee 100644 --- a/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs +++ b/src/Glyph11/Parser/UltraHardened/UltraHardenedParser.FullResponse.ROM.cs @@ -59,9 +59,13 @@ public static bool TryExtractFullResponseHeaderROM( throw new HttpParseException("Invalid status line: missing status code."); // --- Version --- + // Same rule as the request line: a well-formed version this parser does not implement is + // 505, anything else in this position is a malformed status line, 400. var versionSpan = statusLine[..firstSpace]; if (!ParserConstants.IsValidHttpVersion(versionSpan)) - throw new HttpParseException("Invalid HTTP version.", 505); + throw ParserConstants.IsWellFormedHttpVersion(versionSpan) + ? new HttpParseException("Unsupported HTTP version.", 505) + : new HttpParseException("Invalid HTTP version."); response.Version = input[..firstSpace]; diff --git a/tests/Tests/UltraHardenedParser.VersionStatus.cs b/tests/Tests/UltraHardenedParser.VersionStatus.cs new file mode 100644 index 0000000..3f8dcf7 --- /dev/null +++ b/tests/Tests/UltraHardenedParser.VersionStatus.cs @@ -0,0 +1,190 @@ +using System.Buffers; +using System.Text; +using Glyph11; +using Glyph11.Parser; +using Glyph11.Parser.UltraHardened; +using Glyph11.Protocol; +using Glyph11.Utils; + +namespace Tests; + +/// +/// The status code carried by a bad HTTP-version: a version that matches the grammar +/// ("HTTP/" DIGIT "." DIGIT) but is not one the parser implements is 505; bytes that are not +/// an HTTP-version at all are a malformed message, 400. The rule is the same on the request line +/// and the status line. +/// +public class HttpVersionStatusTests : IDisposable +{ + private readonly BinaryRequest _request = new(); + private readonly BinaryResponse _response = new(); + private static readonly ParserLimits Defaults = ParserLimits.Default; + + public void Dispose() + { + _request.Dispose(); + _response.Dispose(); + GC.SuppressFinalize(this); + } + + public static TheoryData Unsupported => + BothSegmentations("HTTP/2.0", "HTTP/1.2", "HTTP/0.9", "HTTP/3.0"); + + public static TheoryData Malformed => + BothSegmentations("http/1.1", "HTTP/1.x", "HTTP1.1", "HTTP/11", "HTTP/1.1x", "HTTP/1.", "HTTP/"); + + public static TheoryData Implemented => + BothSegmentations("HTTP/1.0", "HTTP/1.1"); + + // ================================================================ + // Request line + // ================================================================ + + [Theory] + [MemberData(nameof(Unsupported))] + public void Request_WellFormedUnsupportedVersion_Throws505(string version, bool multi) + { + var ex = Assert.Throws( + () => ParseRequest($"GET / {version}\r\nHost: x\r\n\r\n", multi)); + + Assert.Equal(505, ex.StatusCode); + Assert.False(ex.IsLimitViolation); + } + + [Theory] + [MemberData(nameof(Malformed))] + public void Request_MalformedVersion_Throws400(string version, bool multi) + { + var ex = Assert.Throws( + () => ParseRequest($"GET / {version}\r\nHost: x\r\n\r\n", multi)); + + Assert.Equal(400, ex.StatusCode); + Assert.False(ex.IsLimitViolation); + } + + [Theory] + [MemberData(nameof(Implemented))] + public void Request_ImplementedVersion_Parses(string version, bool multi) + { + Assert.True(ParseRequest($"GET / {version}\r\nHost: x\r\n\r\n", multi)); + AssertAscii.Equal(version, _request.Version); + } + + // ================================================================ + // Status line + // ================================================================ + + [Theory] + [MemberData(nameof(Unsupported))] + public void Response_WellFormedUnsupportedVersion_Throws505(string version, bool multi) + { + var ex = Assert.Throws( + () => ParseResponse($"{version} 200 OK\r\nContent-Length: 0\r\n\r\n", multi)); + + Assert.Equal(505, ex.StatusCode); + Assert.False(ex.IsLimitViolation); + } + + [Theory] + [MemberData(nameof(Malformed))] + public void Response_MalformedVersion_Throws400(string version, bool multi) + { + var ex = Assert.Throws( + () => ParseResponse($"{version} 200 OK\r\nContent-Length: 0\r\n\r\n", multi)); + + Assert.Equal(400, ex.StatusCode); + Assert.False(ex.IsLimitViolation); + } + + [Theory] + [MemberData(nameof(Implemented))] + public void Response_ImplementedVersion_Parses(string version, bool multi) + { + Assert.True(ParseResponse($"{version} 200 OK\r\nContent-Length: 0\r\n\r\n", multi)); + AssertAscii.Equal(version, _response.Version); + } + + // ================================================================ + // IsLimitViolation means 431 and nothing else + // ================================================================ + + [Fact] + public void IsLimitViolation_OnlyFor431() + { + Assert.False(new HttpParseException("structural").IsLimitViolation); + Assert.False(new HttpParseException("unsupported version", 505).IsLimitViolation); + Assert.True(new HttpParseException("too large", 431).IsLimitViolation); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Request_LimitBreach_IsLimitViolation(bool multi) + { + var limits = Defaults with { MaxTotalHeaderBytes = 20 }; + + var ex = Assert.Throws( + () => ParseRequest("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", multi, limits)); + + Assert.Equal(431, ex.StatusCode); + Assert.True(ex.IsLimitViolation); + } + + // ================================================================ + // Helpers + // ================================================================ + + private static TheoryData BothSegmentations(params string[] versions) + { + var data = new TheoryData(); + foreach (var v in versions) + { + data.Add(v, false); + data.Add(v, true); + } + return data; + } + + private bool ParseRequest(string raw, bool multiSegment) + => ParseRequest(raw, multiSegment, Defaults); + + private bool ParseRequest(string raw, bool multiSegment, ParserLimits limits) + { + var bytes = Encoding.ASCII.GetBytes(raw); + + if (multiSegment) + { + var seq = SplitIntoSegments(bytes); + return UltraHardenedParser.TryExtractFullHeaderValidated(ref seq, _request, in limits, out _); + } + + ReadOnlyMemory rom = bytes; + return UltraHardenedParser.TryExtractFullHeaderROM(ref rom, _request, in limits, out _); + } + + private bool ParseResponse(string raw, bool multiSegment) + { + var bytes = Encoding.ASCII.GetBytes(raw); + var limits = Defaults; + + if (multiSegment) + { + var seq = SplitIntoSegments(bytes); + return UltraHardenedParser.TryExtractFullResponseHeaderValidated(ref seq, _response, in limits, out _); + } + + ReadOnlyMemory rom = bytes; + return UltraHardenedParser.TryExtractFullResponseHeaderROM(ref rom, _response, in limits, out _); + } + + private static ReadOnlySequence SplitIntoSegments(byte[] data) + { + int split1 = data.Length / 3; + int split2 = 2 * data.Length / 3; + + var first = new BufferSegment(data.AsMemory(0, split1)); + var last = first.Append(data.AsMemory(split1, split2 - split1)).Append(data.AsMemory(split2)); + + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } +}