From aa5924b29e6938238ee3672fc8733e29180357fc Mon Sep 17 00:00:00 2001
From: Chris Ross <Tratcher@Outlook.com>
Date: Tue, 1 Nov 2022 19:50:40 -0700
Subject: [PATCH] Send 431 when HTTP/2&3 headers are too large or many #33622
 (#44771)

---
 .../Core/src/Internal/Http/HttpProtocol.cs    |  6 +-
 .../src/Internal/Http2/Http2Connection.cs     |  8 ++-
 .../Core/src/Internal/Http2/Http2Stream.cs    | 18 +++++
 .../Core/src/Internal/Http3/Http3Stream.cs    | 23 +++++-
 .../Kestrel/Core/test/Http1ConnectionTests.cs |  1 +
 .../shared/test/Http3/Http3InMemory.cs        |  5 +-
 .../Http2/Http2ConnectionTests.cs             | 14 +++-
 .../Http2/Http2StreamTests.cs                 | 72 +++++++++++++++++++
 .../Http2/Http2TestBase.cs                    |  2 +
 .../Http3/Http3StreamTests.cs                 | 72 ++++++++++++++++++-
 .../Http3/Http3TestBase.cs                    |  2 +
 .../HttpClientHttp2InteropTests.cs            |  6 +-
 12 files changed, 212 insertions(+), 17 deletions(-)

diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs
index 583573bdbfb..2d81159310d 100644
--- a/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs
+++ b/src/Servers/Kestrel/Core/src/Internal/Http/HttpProtocol.cs
@@ -68,6 +68,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
 
         private string? _requestId;
         private int _requestHeadersParsed;
+        // See MaxRequestHeaderCount, enforced during parsing and may be more relaxed to avoid connection faults.
+        protected int _eagerRequestHeadersParsedLimit;
 
         private long _responseBytesWritten;
 
@@ -112,6 +114,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
         public long? MaxRequestBodySize { get; set; }
         public MinDataRate? MinRequestBodyDataRate { get; set; }
         public bool AllowSynchronousIO { get; set; }
+        protected int RequestHeadersParsed => _requestHeadersParsed;
 
         /// <summary>
         /// The request id. <seealso cref="HttpContext.TraceIdentifier"/>
@@ -413,6 +416,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
             Output?.Reset();
 
             _requestHeadersParsed = 0;
+            _eagerRequestHeadersParsedLimit = ServerOptions.Limits.MaxRequestHeaderCount;
 
             _responseBytesWritten = 0;
 
@@ -544,7 +548,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
         private void IncrementRequestHeadersCount()
         {
             _requestHeadersParsed++;
-            if (_requestHeadersParsed > ServerOptions.Limits.MaxRequestHeaderCount)
+            if (_requestHeadersParsed > _eagerRequestHeadersParsedLimit)
             {
                 KestrelBadHttpRequestException.Throw(RequestRejectionReason.TooManyHeaders);
             }
diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs
index 2f54decb91b..b197c7fccb2 100644
--- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs
+++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs
@@ -1019,6 +1019,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2
 
             try
             {
+                _currentHeadersStream.TotalParsedHeaderSize = _totalParsedHeaderSize;
+
                 // This must be initialized before we offload the request or else we may start processing request body frames without it.
                 _currentHeadersStream.InputRemaining = _currentHeadersStream.RequestHeaders.ContentLength;
 
@@ -1279,8 +1281,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2
 
             // https://tools.ietf.org/html/rfc7540#section-6.5.2
             // "The value is based on the uncompressed size of header fields, including the length of the name and value in octets plus an overhead of 32 octets for each header field.";
-            _totalParsedHeaderSize += HeaderField.RfcOverhead + name.Length + value.Length;
-            if (_totalParsedHeaderSize > _context.ServiceContext.ServerOptions.Limits.MaxRequestHeadersTotalSize)
+            // We don't include the 32 byte overhead hear so we can accept a little more than the advertised limit.
+            _totalParsedHeaderSize += name.Length + value.Length;
+            // Allow a 2x grace before aborting the connection. We'll check the size limit again later where we can send a 431.
+            if (_totalParsedHeaderSize > _context.ServiceContext.ServerOptions.Limits.MaxRequestHeadersTotalSize * 2)
             {
                 throw new Http2ConnectionErrorException(CoreStrings.BadRequest_HeadersExceedMaxTotalSize, Http2ErrorCode.PROTOCOL_ERROR);
             }
diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs
index 685b25220e1..47812b0c9df 100644
--- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs
+++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs
@@ -31,6 +31,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2
 
         private bool _decrementCalled;
 
+        public int TotalParsedHeaderSize { get; set; }
+
         public Pipe RequestBodyPipe { get; private set; } = default!;
 
         internal long DrainExpirationTicks { get; set; }
@@ -47,6 +49,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2
             InputRemaining = null;
             RequestBodyStarted = false;
             DrainExpirationTicks = 0;
+            TotalParsedHeaderSize = 0;
+            // Allow up to 2x during parsing, enforce the hard limit after when we can preserve the connection.
+            _eagerRequestHeadersParsedLimit = ServerOptions.Limits.MaxRequestHeaderCount * 2;
 
             _context = context;
 
@@ -208,6 +213,19 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2
             // We don't need any of the parameters because we don't implement BeginRead to actually
             // do the reading from a pipeline, nor do we use endConnection to report connection-level errors.
             endConnection = !TryValidatePseudoHeaders();
+
+            // 431 if the headers are too large
+            if (TotalParsedHeaderSize > ServerOptions.Limits.MaxRequestHeadersTotalSize)
+            {
+                KestrelBadHttpRequestException.Throw(RequestRejectionReason.HeadersExceedMaxTotalSize);
+            }
+
+            // 431 if we received too many headers
+            if (RequestHeadersParsed > ServerOptions.Limits.MaxRequestHeaderCount)
+            {
+                KestrelBadHttpRequestException.Throw(RequestRejectionReason.TooManyHeaders);
+            }
+
             return true;
         }
 
diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs
index 0f862c69fe8..31fdab59d66 100644
--- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs
+++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs
@@ -92,6 +92,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3
             _requestHeaderParsingState = default;
             _parsedPseudoHeaderFields = default;
             _totalParsedHeaderSize = 0;
+            // Allow up to 2x during parsing, enforce the hard limit after when we can preserve the connection.
+            _eagerRequestHeadersParsedLimit = ServerOptions.Limits.MaxRequestHeaderCount * 2;
             _isMethodConnect = false;
             _completionState = default;
             StreamTimeoutTicks = 0;
@@ -205,10 +207,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3
 
         public override void OnHeader(ReadOnlySpan<byte> name, ReadOnlySpan<byte> value, bool checkForNewlineChars)
         {
-            // https://tools.ietf.org/html/rfc7540#section-6.5.2
+            // https://httpwg.org/specs/rfc9114.html#rfc.section.4.2.2
             // "The value is based on the uncompressed size of header fields, including the length of the name and value in octets plus an overhead of 32 octets for each header field.";
-            _totalParsedHeaderSize += HeaderField.RfcOverhead + name.Length + value.Length;
-            if (_totalParsedHeaderSize > _context.ServiceContext.ServerOptions.Limits.MaxRequestHeadersTotalSize)
+            // We don't include the 32 byte overhead hear so we can accept a little more than the advertised limit.
+            _totalParsedHeaderSize += name.Length + value.Length;
+            // Allow a 2x grace before aborting the stream. We'll check the size limit again later where we can send a 431.
+            if (_totalParsedHeaderSize > ServerOptions.Limits.MaxRequestHeadersTotalSize * 2)
             {
                 throw new Http3StreamErrorException(CoreStrings.BadRequest_HeadersExceedMaxTotalSize, Http3ErrorCode.RequestRejected);
             }
@@ -754,6 +758,19 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http3
         protected override bool TryParseRequest(ReadResult result, out bool endConnection)
         {
             endConnection = !TryValidatePseudoHeaders();
+
+            // 431 if the headers are too large
+            if (_totalParsedHeaderSize > ServerOptions.Limits.MaxRequestHeadersTotalSize)
+            {
+                KestrelBadHttpRequestException.Throw(RequestRejectionReason.HeadersExceedMaxTotalSize);
+            }
+
+            // 431 if we received too many headers
+            if (RequestHeadersParsed > ServerOptions.Limits.MaxRequestHeaderCount)
+            {
+                KestrelBadHttpRequestException.Throw(RequestRejectionReason.TooManyHeaders);
+            }
+
             return true;
         }
 
diff --git a/src/Servers/Kestrel/Core/test/Http1ConnectionTests.cs b/src/Servers/Kestrel/Core/test/Http1ConnectionTests.cs
index bb3071ef4a9..d1f2fa29705 100644
--- a/src/Servers/Kestrel/Core/test/Http1ConnectionTests.cs
+++ b/src/Servers/Kestrel/Core/test/Http1ConnectionTests.cs
@@ -137,6 +137,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         {
             const string headerLines = "Header-1: value1\r\nHeader-2: value2\r\n";
             _serviceContext.ServerOptions.Limits.MaxRequestHeaderCount = 1;
+            _http1Connection.Initialize(_http1ConnectionContext);
 
             await _application.Output.WriteAsync(Encoding.ASCII.GetBytes($"{headerLines}\r\n"));
             var readableBuffer = (await _transport.Input.ReadAsync()).Buffer;
diff --git a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs
index af0a22c3ece..9a98b849280 100644
--- a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs
+++ b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs
@@ -590,7 +590,7 @@ namespace Microsoft.AspNetCore.Testing
 
     internal class Http3RequestHeaderHandler
     {
-        public readonly byte[] HeaderEncodingBuffer = new byte[64 * 1024];
+        public readonly byte[] HeaderEncodingBuffer = new byte[96 * 1024];
         public readonly QPackDecoder QpackDecoder = new QPackDecoder(8192);
         public readonly Dictionary<string, string> DecodedHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
     }
@@ -639,9 +639,8 @@ namespace Microsoft.AspNetCore.Testing
             var done = QPackHeaderWriter.BeginEncode(headers, buffer.Span, ref headersTotalSize, out var length);
             if (!done)
             {
-                throw new InvalidOperationException("Headers not sent.");
+                throw new InvalidOperationException("The headers are too large.");
             }
-
             await SendFrameAsync(Http3FrameType.Headers, buffer.Slice(0, length), endStream);
         }
 
diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs
index 6a9656d9f73..e83b91a349f 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs
+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs
@@ -2712,7 +2712,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         [Fact]
         public Task HEADERS_Received_HeaderBlockOverLimit_ConnectionError()
         {
-            // > 32kb
+            // > 32kb * 2 to exceed graceful handling limit
             var headers = new[]
             {
                 new KeyValuePair<string, string>(HeaderNames.Method, "GET"),
@@ -2726,6 +2726,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
                 new KeyValuePair<string, string>("f", _4kHeaderValue),
                 new KeyValuePair<string, string>("g", _4kHeaderValue),
                 new KeyValuePair<string, string>("h", _4kHeaderValue),
+                new KeyValuePair<string, string>("i", _4kHeaderValue),
+                new KeyValuePair<string, string>("j", _4kHeaderValue),
+                new KeyValuePair<string, string>("k", _4kHeaderValue),
+                new KeyValuePair<string, string>("l", _4kHeaderValue),
+                new KeyValuePair<string, string>("m", _4kHeaderValue),
+                new KeyValuePair<string, string>("n", _4kHeaderValue),
+                new KeyValuePair<string, string>("o", _4kHeaderValue),
+                new KeyValuePair<string, string>("p", _4kHeaderValue),
             };
 
             return HEADERS_Received_InvalidHeaderFields_ConnectionError(headers, CoreStrings.BadRequest_HeadersExceedMaxTotalSize);
@@ -2734,7 +2742,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         [Fact]
         public Task HEADERS_Received_TooManyHeaders_ConnectionError()
         {
-            // > MaxRequestHeaderCount (100)
+            // > MaxRequestHeaderCount (100) * 2 to exceed graceful handling limit
             var headers = new List<KeyValuePair<string, string>>();
             headers.AddRange(new[]
             {
@@ -2742,7 +2750,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
                 new KeyValuePair<string, string>(HeaderNames.Path, "/"),
                 new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
             });
-            for (var i = 0; i < 100; i++)
+            for (var i = 0; i < 200; i++)
             {
                 headers.Add(new KeyValuePair<string, string>(i.ToString(CultureInfo.InvariantCulture), i.ToString(CultureInfo.InvariantCulture)));
             }
diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs
index 0fe7184e2e9..97f0d80506e 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs
+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2StreamTests.cs
@@ -4,6 +4,7 @@
 using System;
 using System.Buffers;
 using System.Collections.Generic;
+using System.Globalization;
 using System.IO;
 using System.Linq;
 using System.Net.Http;
@@ -740,6 +741,77 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
             await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
         }
 
+        [Fact]
+        public async Task HEADERS_Received_MaxRequestHeadersTotalSize_431()
+        {
+            // > 32kb
+            var headers = new[]
+            {
+                new KeyValuePair<string, string>(HeaderNames.Method, "GET"),
+                new KeyValuePair<string, string>(HeaderNames.Path, "/"),
+                new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
+                new KeyValuePair<string, string>("a", _4kHeaderValue),
+                new KeyValuePair<string, string>("b", _4kHeaderValue),
+                new KeyValuePair<string, string>("c", _4kHeaderValue),
+                new KeyValuePair<string, string>("d", _4kHeaderValue),
+                new KeyValuePair<string, string>("e", _4kHeaderValue),
+                new KeyValuePair<string, string>("f", _4kHeaderValue),
+                new KeyValuePair<string, string>("g", _4kHeaderValue),
+                new KeyValuePair<string, string>("h", _4kHeaderValue),
+            };
+            await InitializeConnectionAsync(_notImplementedApp);
+
+            await StartStreamAsync(1, headers, endStream: true);
+
+            var headersFrame = await ExpectAsync(Http2FrameType.HEADERS,
+                withLength: 40,
+                withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM),
+                withStreamId: 1);
+
+            await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
+
+            _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: true, handler: this);
+
+            Assert.Equal(3, _decodedHeaders.Count);
+            Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
+            Assert.Equal("431", _decodedHeaders[HeaderNames.Status]);
+            Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]);
+        }
+
+        [Fact]
+        public async Task HEADERS_Received_MaxRequestHeaderCount_431()
+        {
+            // > 100 headers
+            var headers = new List<KeyValuePair<string, string>>()
+            {
+                new KeyValuePair<string, string>(HeaderNames.Method, "GET"),
+                new KeyValuePair<string, string>(HeaderNames.Path, "/"),
+                new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
+            };
+            for (var i = 0; i < 101; i++)
+            {
+                var text = i.ToString(CultureInfo.InvariantCulture);
+                headers.Add(new KeyValuePair<string, string>(text, text));
+            }
+            await InitializeConnectionAsync(_notImplementedApp);
+
+            await StartStreamAsync(1, headers, endStream: true);
+
+            var headersFrame = await ExpectAsync(Http2FrameType.HEADERS,
+                withLength: 40,
+                withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM),
+                withStreamId: 1);
+
+            await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
+
+            _hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: true, handler: this);
+
+            Assert.Equal(3, _decodedHeaders.Count);
+            Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
+            Assert.Equal("431", _decodedHeaders[HeaderNames.Status]);
+            Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]);
+        }
+
         [Fact]
         public async Task ContentLength_Received_SingleDataFrame_Verified()
         {
diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs
index e0d92c63c8b..4fc1e1e0cc1 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs
+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs
@@ -143,6 +143,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         protected readonly TaskCompletionSource _closedStateReached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
         protected readonly RequestDelegate _noopApplication;
+        protected readonly RequestDelegate _notImplementedApp;
         protected readonly RequestDelegate _readHeadersApplication;
         protected readonly RequestDelegate _readTrailersApplication;
         protected readonly RequestDelegate _bufferingApplication;
@@ -193,6 +194,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
             });
 
             _noopApplication = context => Task.CompletedTask;
+            _notImplementedApp = _ => throw new NotImplementedException();
 
             _readHeadersApplication = context =>
             {
diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs
index 6a82f44102c..2fc056b6bdd 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs
+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3StreamTests.cs
@@ -2319,7 +2319,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         }
 
         [Fact]
-        public Task HEADERS_Received_HeaderBlockOverLimit_ConnectionError()
+        public async Task HEADERS_Received_HeaderBlockOverLimit_431()
         {
             // > 32kb
             var headers = new[]
@@ -2336,12 +2336,51 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
                 new KeyValuePair<string, string>("g", _4kHeaderValue),
                 new KeyValuePair<string, string>("h", _4kHeaderValue),
             };
+            var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_noopApplication);
+            await requestStream.SendHeadersAsync(headers, endStream: true);
+
+            var receivedHeaders = await requestStream.ExpectHeadersAsync();
+
+            await requestStream.ExpectReceiveEndOfStream();
+
+            Assert.Equal(3, receivedHeaders.Count);
+            Assert.Contains("date", receivedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
+            Assert.Equal("431", receivedHeaders[HeaderNames.Status]);
+            Assert.Equal("0", receivedHeaders[HeaderNames.ContentLength]);
+        }
+
+        [Fact]
+        public Task HEADERS_Received_HeaderBlockOverLimitx2_ConnectionError()
+        {
+            // > 32kb * 2 to exceed graceful handling limit
+            var headers = new[]
+            {
+                new KeyValuePair<string, string>(HeaderNames.Method, "GET"),
+                new KeyValuePair<string, string>(HeaderNames.Path, "/"),
+                new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
+                new KeyValuePair<string, string>("a", _4kHeaderValue),
+                new KeyValuePair<string, string>("b", _4kHeaderValue),
+                new KeyValuePair<string, string>("c", _4kHeaderValue),
+                new KeyValuePair<string, string>("d", _4kHeaderValue),
+                new KeyValuePair<string, string>("e", _4kHeaderValue),
+                new KeyValuePair<string, string>("f", _4kHeaderValue),
+                new KeyValuePair<string, string>("g", _4kHeaderValue),
+                new KeyValuePair<string, string>("h", _4kHeaderValue),
+                new KeyValuePair<string, string>("i", _4kHeaderValue),
+                new KeyValuePair<string, string>("j", _4kHeaderValue),
+                new KeyValuePair<string, string>("k", _4kHeaderValue),
+                new KeyValuePair<string, string>("l", _4kHeaderValue),
+                new KeyValuePair<string, string>("m", _4kHeaderValue),
+                new KeyValuePair<string, string>("n", _4kHeaderValue),
+                new KeyValuePair<string, string>("o", _4kHeaderValue),
+                new KeyValuePair<string, string>("p", _4kHeaderValue),
+            };
 
             return HEADERS_Received_InvalidHeaderFields_StreamError(headers, CoreStrings.BadRequest_HeadersExceedMaxTotalSize, Http3ErrorCode.RequestRejected);
         }
 
         [Fact]
-        public Task HEADERS_Received_TooManyHeaders_ConnectionError()
+        public async Task HEADERS_Received_TooManyHeaders_431()
         {
             // > MaxRequestHeaderCount (100)
             var headers = new List<KeyValuePair<string, string>>();
@@ -2356,6 +2395,35 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
                 headers.Add(new KeyValuePair<string, string>(i.ToString(CultureInfo.InvariantCulture), i.ToString(CultureInfo.InvariantCulture)));
             }
 
+            var requestStream = await Http3Api.InitializeConnectionAndStreamsAsync(_notImplementedApp);
+            await requestStream.SendHeadersAsync(headers, endStream: true);
+
+            var receivedHeaders = await requestStream.ExpectHeadersAsync();
+
+            await requestStream.ExpectReceiveEndOfStream();
+
+            Assert.Equal(3, receivedHeaders.Count);
+            Assert.Contains("date", receivedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
+            Assert.Equal("431", receivedHeaders[HeaderNames.Status]);
+            Assert.Equal("0", receivedHeaders[HeaderNames.ContentLength]);
+        }
+
+        [Fact]
+        public Task HEADERS_Received_TooManyHeadersx2_ConnectionError()
+        {
+            // > MaxRequestHeaderCount (100) * 2 to exceed graceful handling limit
+            var headers = new List<KeyValuePair<string, string>>();
+            headers.AddRange(new[]
+            {
+                new KeyValuePair<string, string>(HeaderNames.Method, "GET"),
+                new KeyValuePair<string, string>(HeaderNames.Path, "/"),
+                new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
+            });
+            for (var i = 0; i < 200; i++)
+            {
+                headers.Add(new KeyValuePair<string, string>(i.ToString(CultureInfo.InvariantCulture), i.ToString(CultureInfo.InvariantCulture)));
+            }
+
             return HEADERS_Received_InvalidHeaderFields_StreamError(headers, CoreStrings.BadRequest_TooManyHeaders);
         }
 
diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TestBase.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TestBase.cs
index be046dc2614..b0ce32f5ef0 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TestBase.cs
+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TestBase.cs
@@ -50,6 +50,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         internal readonly Mock<ITimeoutHandler> _mockTimeoutHandler = new Mock<ITimeoutHandler>();
 
         protected readonly RequestDelegate _noopApplication;
+        protected readonly RequestDelegate _notImplementedApp;
         protected readonly RequestDelegate _echoApplication;
         protected readonly RequestDelegate _readRateApplication;
         protected readonly RequestDelegate _echoMethod;
@@ -80,6 +81,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
         public Http3TestBase()
         {
             _noopApplication = context => Task.CompletedTask;
+            _notImplementedApp = _ => throw new NotImplementedException();
 
             _echoApplication = async context =>
             {
diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs
index be2eaa97e93..ee85ae94b7a 100644
--- a/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs
+++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/HttpClientHttp2InteropTests.cs
@@ -1366,10 +1366,10 @@ namespace Interop.FunctionalTests
             {
                 request.Headers.Add("header" + i, oneKbString + i);
             }
-            // Kestrel closes the connection rather than sending the recommended 431 response. https://github.com/dotnet/aspnetcore/issues/17861
-            await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(request)).DefaultTimeout();
-
+            var response = await client.SendAsync(request).DefaultTimeout();
             await host.StopAsync().DefaultTimeout();
+
+            Assert.Equal(HttpStatusCode.RequestHeaderFieldsTooLarge, response.StatusCode);
         }
 
         [Theory]
-- 
GitLab