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
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 6 additions & 3 deletions src/Glyph11/HttpParseException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ public HttpParseException(string message, int statusCode = 400) : base(message)
StatusCode = statusCode;
}

/// <summary>The HTTP status code to return (400 for structural errors, 431 for limit breaches).</summary>
/// <summary>
/// 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.
/// </summary>
public int StatusCode { get; }

/// <summary>True when the error is a size/count limit breach (→ 431), false for structural errors (→ 400).</summary>
public bool IsLimitViolation => StatusCode != 400;
/// <summary>True when the error is a size/count limit breach (→ 431); false otherwise (400, 505).</summary>
public bool IsLimitViolation => StatusCode == 431;
}
24 changes: 22 additions & 2 deletions src/Glyph11/Parser/ParserConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,24 @@
public static class ParserConstants
{
// ---- Line terminators ----
public static ReadOnlySpan<byte> Crlf => "\r\n"u8;

Check warning on line 12 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.Crlf'

Check warning on line 12 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.Crlf'
public static ReadOnlySpan<byte> CrlfCrlf => "\r\n\r\n"u8;

Check warning on line 13 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.CrlfCrlf'

Check warning on line 13 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.CrlfCrlf'

// ---- Special bytes ----
public const byte Space = 0x20; // ' '

Check warning on line 16 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.Space'

Check warning on line 16 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.Space'
public const byte Question = 0x3F; // '?'

Check warning on line 17 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.Question'

Check warning on line 17 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.Question'
public const byte QuerySeparator = 0x26; // '&'

Check warning on line 18 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.QuerySeparator'

Check warning on line 18 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.QuerySeparator'
public const byte Equal = 0x3D; // '='

Check warning on line 19 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.Equal'

Check warning on line 19 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.Equal'
public const byte Colon = 0x3A; // ':'

Check warning on line 20 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.Colon'

Check warning on line 20 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.Colon'

// ---- SIMD-accelerated character class validators (SearchValues<byte>) ----

// Token chars (RFC 9110 §5.6.2): !#$%&'*+-.^_`|~ DIGIT ALPHA
public static readonly SearchValues<byte> TokenSearchValues = SearchValues.Create(

Check warning on line 25 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Test & Coverage

Missing XML comment for publicly visible type or member 'ParserConstants.TokenSearchValues'

Check warning on line 25 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.TokenSearchValues'
"!#$%&'*+-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz|~"u8);

// Field-value chars (RFC 9110 §5.5): HTAB SP VCHAR obs-text
public static readonly SearchValues<byte> FieldValueSearchValues = SearchValues.Create(

Check warning on line 29 in src/Glyph11/Parser/ParserConstants.cs

View workflow job for this annotation

GitHub Actions / Build and analyze

Missing XML comment for publicly visible type or member 'ParserConstants.FieldValueSearchValues'
BuildFieldValueBytes());

// Request-target: only VCHAR (0x21-0x7E) and SP (0x20) — RFC 3986 URIs are ASCII-only
Expand Down Expand Up @@ -63,8 +63,9 @@
=> span.IndexOfAnyExcept(FieldValueSearchValues) < 0;

/// <summary>
/// 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,
/// <c>HTTP/1.0</c> or <c>HTTP/1.1</c>.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsValidHttpVersion(ReadOnlySpan<byte> span)
Expand All @@ -80,6 +81,25 @@
&& (span[7] == (byte)'0' || span[7] == (byte)'1');
}

/// <summary>
/// True if <paramref name="span"/> matches the HTTP-version grammar
/// (<c>"HTTP/" DIGIT "." DIGIT</c>, 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).
/// </summary>
internal static bool IsWellFormedHttpVersion(ReadOnlySpan<byte> 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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
190 changes: 190 additions & 0 deletions tests/Tests/UltraHardenedParser.VersionStatus.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The status code carried by a bad HTTP-version: a version that matches the grammar
/// (<c>"HTTP/" DIGIT "." DIGIT</c>) 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.
/// </summary>
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<string, bool> Unsupported =>
BothSegmentations("HTTP/2.0", "HTTP/1.2", "HTTP/0.9", "HTTP/3.0");

public static TheoryData<string, bool> Malformed =>
BothSegmentations("http/1.1", "HTTP/1.x", "HTTP1.1", "HTTP/11", "HTTP/1.1x", "HTTP/1.", "HTTP/");

public static TheoryData<string, bool> 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<HttpParseException>(
() => 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<HttpParseException>(
() => 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<HttpParseException>(
() => 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<HttpParseException>(
() => 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<HttpParseException>(
() => 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<string, bool> BothSegmentations(params string[] versions)
{
var data = new TheoryData<string, bool>();
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<byte> 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<byte> rom = bytes;
return UltraHardenedParser.TryExtractFullResponseHeaderROM(ref rom, _response, in limits, out _);
}

private static ReadOnlySequence<byte> 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<byte>(first, 0, last, last.Memory.Length);
}
}
Loading