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);
+ }
+}