From 423e0499c3c61ed91df6bf5a9e9d903e9e0d3061 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 7 Jul 2026 11:52:13 +0200 Subject: [PATCH 1/7] Kestrel: reject Content-Length with leading sign per RFC 9112 RFC 9112 section 6.2 defines Content-Length as 1*DIGIT. The HTTP/1.1 request header parser used Utf8Parser.TryParse, which accepts a leading '+' or '-' sign. Values like '+123' or '-0' were therefore accepted (parsed as 123 and 0) instead of being rejected with 400 Bad Request. The custom-encoding path used long.TryParse with NumberStyles.Integer, which also allows a leading sign, so it had the same defect. Add a leading-digit check on the byte path and switch the custom-encoding path to NumberStyles.None. Extend BadRequestIfContentLengthInvalid to cover '+1', '+0', '-0'. --- .../Core/src/Internal/Http/HttpRequestHeaders.cs | 12 ++++++++++-- .../InMemory.FunctionalTests/BadHttpRequestTests.cs | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index cdd6ecac601d..ea8dd94557d1 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -98,7 +98,13 @@ private void AppendContentLength(ReadOnlySpan value) KestrelBadHttpRequestException.Throw(RequestRejectionReason.MultipleContentLengths); } - if (!Utf8Parser.TryParse(value, out long parsed, out var consumed) || + // Per RFC 9112 §6.2, Content-Length is 1*DIGIT: only ASCII digits are allowed. + // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so checking first symbol is ASCII digit + long parsed = -1; + var consumed = 0; + if (value.IsEmpty || + !char.IsAsciiDigit((char)value[0]) || + !Utf8Parser.TryParse(value, out parsed, out consumed) || parsed < 0 || consumed != value.Length) { @@ -122,8 +128,10 @@ private unsafe void AppendContentLengthCustomEncoding(ReadOnlySpan value, var numChars = customEncoding.GetChars(value, decodedChars); long parsed = -1; + // Per RFC 9112 §6.2, Content-Length is 1*DIGIT. NumberStyles.None + // disallows leading signs and whitespace that NumberStyles.Integer would accept. if (numChars > 19 || - !long.TryParse(decodedChars.Slice(0, numChars), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed) || + !long.TryParse(decodedChars.Slice(0, numChars), NumberStyles.None, CultureInfo.InvariantCulture, out parsed) || parsed < 0) { KestrelBadHttpRequestException.Throw(RequestRejectionReason.InvalidContentLength, value.GetRequestHeaderString(HeaderNames.ContentLength, EncodingSelector, checkForNewlineChars: false)); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs index 05481529b5ea..5f4c0d0e9565 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs @@ -100,6 +100,9 @@ public Task BadRequestIfMethodRequiresLengthButNoContentLengthInHttp10Request(st [Theory] [InlineData("NaN")] [InlineData("-1")] + [InlineData("+1")] + [InlineData("+0")] + [InlineData("-0")] public Task BadRequestIfContentLengthInvalid(string contentLength) { return TestBadRequest( From a292d2dc281663e56d5fab5420856033a8474ef1 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 7 Jul 2026 14:24:21 +0200 Subject: [PATCH 2/7] simplify --- .../Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index ea8dd94557d1..5e517e509daf 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -101,10 +101,9 @@ private void AppendContentLength(ReadOnlySpan value) // Per RFC 9112 §6.2, Content-Length is 1*DIGIT: only ASCII digits are allowed. // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so checking first symbol is ASCII digit long parsed = -1; - var consumed = 0; if (value.IsEmpty || !char.IsAsciiDigit((char)value[0]) || - !Utf8Parser.TryParse(value, out parsed, out consumed) || + !Utf8Parser.TryParse(value, out parsed, out var consumed) || parsed < 0 || consumed != value.Length) { From 8d8d7e5a8e6ba518f42bfdd53e3d4a0b41533f18 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 7 Jul 2026 14:36:43 +0200 Subject: [PATCH 3/7] fix rfc + section --- .../Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index 5e517e509daf..62ca16573db6 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -98,7 +98,7 @@ private void AppendContentLength(ReadOnlySpan value) KestrelBadHttpRequestException.Throw(RequestRejectionReason.MultipleContentLengths); } - // Per RFC 9112 §6.2, Content-Length is 1*DIGIT: only ASCII digits are allowed. + // Per RFC 9110 §8.6, Content-Length is 1*DIGIT: only ASCII digits are allowed. // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so checking first symbol is ASCII digit long parsed = -1; if (value.IsEmpty || @@ -127,7 +127,7 @@ private unsafe void AppendContentLengthCustomEncoding(ReadOnlySpan value, var numChars = customEncoding.GetChars(value, decodedChars); long parsed = -1; - // Per RFC 9112 §6.2, Content-Length is 1*DIGIT. NumberStyles.None + // Per RFC 9110 §8.6, Content-Length is 1*DIGIT. NumberStyles.None // disallows leading signs and whitespace that NumberStyles.Integer would accept. if (numChars > 19 || !long.TryParse(decodedChars.Slice(0, numChars), NumberStyles.None, CultureInfo.InvariantCulture, out parsed) || From 44b7b2669b75cfd5aafd92b2840de93a2ec06557 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 7 Jul 2026 14:36:43 +0200 Subject: [PATCH 4/7] fix rfc + section --- .../src/Internal/Http/HttpRequestHeaders.cs | 2 ++ .../Core/test/HttpRequestHeadersTests.cs | 24 +++++++++++++++++++ .../BadHttpRequestTests.cs | 1 + 3 files changed, 27 insertions(+) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index 62ca16573db6..2269b44dc4dd 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -99,6 +99,7 @@ private void AppendContentLength(ReadOnlySpan value) } // Per RFC 9110 §8.6, Content-Length is 1*DIGIT: only ASCII digits are allowed. + // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so checking first symbol is ASCII digit long parsed = -1; if (value.IsEmpty || @@ -128,6 +129,7 @@ private unsafe void AppendContentLengthCustomEncoding(ReadOnlySpan value, long parsed = -1; // Per RFC 9110 §8.6, Content-Length is 1*DIGIT. NumberStyles.None + // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 // disallows leading signs and whitespace that NumberStyles.Integer would accept. if (numChars > 19 || !long.TryParse(decodedChars.Slice(0, numChars), NumberStyles.None, CultureInfo.InvariantCulture, out parsed) || diff --git a/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs b/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs index 7a9b6dea0f78..3cf7006cbd6f 100644 --- a/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs +++ b/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs @@ -710,6 +710,30 @@ public void CanSpecifyEncodingForContentLength() new HttpRequestHeaders().Append(contentLengthNameBytes, contentLengthValueBytes, checkForNewlineChars: false)); } + // Per RFC 9110 §8.6, Content-Length is 1*DIGIT. + // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 + // Covers the AppendContentLengthCustomEncoding path (custom RequestHeaderEncodingSelector). + [Theory] + [InlineData("+1")] + [InlineData("+0")] + [InlineData("-0")] + [InlineData("-1")] + [InlineData(" 1")] + [InlineData("1a")] + [InlineData("")] + public void ContentLengthWithCustomEncodingRejectsNonDigits(string value) + { + var contentLengthNameBytes = Encoding.ASCII.GetBytes(HeaderNames.ContentLength); + var contentLengthValueBytes = Encoding.UTF32.GetBytes(value); + + var headers = new HttpRequestHeaders(encodingSelector: _ => Encoding.UTF32); + +#pragma warning disable CS0618 // Type or member is obsolete + Assert.Throws(() => +#pragma warning restore CS0618 // Type or member is obsolete + headers.Append(contentLengthNameBytes, contentLengthValueBytes, checkForNewlineChars: false)); + } + [Fact] public void ValueReuseNeverWhenUnknownHeader() { diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs index 5f4c0d0e9565..cb49b2822307 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/BadHttpRequestTests.cs @@ -103,6 +103,7 @@ public Task BadRequestIfMethodRequiresLengthButNoContentLengthInHttp10Request(st [InlineData("+1")] [InlineData("+0")] [InlineData("-0")] + [InlineData("")] public Task BadRequestIfContentLengthInvalid(string contentLength) { return TestBadRequest( From 61b4d8418e17e803285927a38341df129a6a5f3c Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 8 Jul 2026 10:43:45 +0200 Subject: [PATCH 5/7] remove rfc from test link --- src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs b/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs index 3cf7006cbd6f..66aca720f271 100644 --- a/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs +++ b/src/Servers/Kestrel/Core/test/HttpRequestHeadersTests.cs @@ -710,9 +710,6 @@ public void CanSpecifyEncodingForContentLength() new HttpRequestHeaders().Append(contentLengthNameBytes, contentLengthValueBytes, checkForNewlineChars: false)); } - // Per RFC 9110 §8.6, Content-Length is 1*DIGIT. - // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 - // Covers the AppendContentLengthCustomEncoding path (custom RequestHeaderEncodingSelector). [Theory] [InlineData("+1")] [InlineData("+0")] From 94f04f688d895d284af9f4c3139e7cc1167a0382 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 8 Jul 2026 20:22:12 +0200 Subject: [PATCH 6/7] nit check --- .../Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index 2269b44dc4dd..22f76ff031e5 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -100,10 +100,10 @@ private void AppendContentLength(ReadOnlySpan value) // Per RFC 9110 §8.6, Content-Length is 1*DIGIT: only ASCII digits are allowed. // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 - // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so checking first symbol is ASCII digit - long parsed = -1; + // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so ensure the first byte is a digit. + long parsed; if (value.IsEmpty || - !char.IsAsciiDigit((char)value[0]) || + value[0] is < (byte)'0' or > (byte)'9' || !Utf8Parser.TryParse(value, out parsed, out var consumed) || parsed < 0 || consumed != value.Length) From 72acc6b877e8160c221d6965ca28c4ae8112255a Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Wed, 8 Jul 2026 21:40:06 +0200 Subject: [PATCH 7/7] fix CS0165: initialize parsed local KestrelBadHttpRequestException.Throw is not annotated [DoesNotReturn], so the compiler treats the throw path as reachable. The IsEmpty and byte-range short-circuits can bypass Utf8Parser.TryParse without assigning 'parsed'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs index 22f76ff031e5..3fbcc4854f42 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpRequestHeaders.cs @@ -101,7 +101,7 @@ private void AppendContentLength(ReadOnlySpan value) // Per RFC 9110 §8.6, Content-Length is 1*DIGIT: only ASCII digits are allowed. // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.6 // Utf8Parser.TryParse accepts a leading '+' or '-' sign, so ensure the first byte is a digit. - long parsed; + long parsed = 0; if (value.IsEmpty || value[0] is < (byte)'0' or > (byte)'9' || !Utf8Parser.TryParse(value, out parsed, out var consumed) ||