From 0322b7c972152d06512085b95326a735d7f4638d Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Fri, 3 Jul 2026 14:49:18 +0200 Subject: [PATCH] Reject connection-specific headers sent via HPACK/QPACK indexed names Connection-specific header fields (e.g. Transfer-Encoding) were only validated on the literal name+value path. Using HPACK's indexed-name representation (transfer-encoding is static table index 57) routed the header through OnStaticIndexedHeader/OnDynamicIndexedHeader, skipping the IsConnectionSpecificHeaderField check and allowing Transfer-Encoding: chunked through on HTTP/2. HTTP/3 shares the same structural gap via QPACK dynamic-table indexed names. Move the connection-specific header check so it runs for every header representation in both Http2Connection and Http3Stream. No legitimate headers are affected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Internal/Http2/Http2Connection.cs | 14 +++++---- .../Core/src/Internal/Http3/Http3Stream.cs | 14 +++++---- .../Http2/Http2ConnectionTests.cs | 29 +++++++++++++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs index 9da2dd9e671f..261e550991a3 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs @@ -1619,6 +1619,15 @@ private void OnHeaderCore(HeaderType headerType, int? staticTableIndex, ReadOnly try { + // https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2 + // Connection-specific header fields make a message malformed regardless of how the header name was + // encoded. HPACK's indexed-name representation (e.g. the "transfer-encoding" entry at static table + // index 57) would otherwise let these fields bypass the check below, so validate every header type. + if (IsConnectionSpecificHeaderField(name, value)) + { + throw new Http2ConnectionErrorException(CoreStrings.HttpErrorConnectionSpecificHeaderField, Http2ErrorCode.PROTOCOL_ERROR, ConnectionEndReason.InvalidRequestHeaders); + } + if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) { // Just use name + value bytes and do full validation for request trailers. @@ -1694,11 +1703,6 @@ public void OnHeadersComplete(bool endStream) private void ValidateHeaderContent(ReadOnlySpan name, ReadOnlySpan value) { - if (IsConnectionSpecificHeaderField(name, value)) - { - throw new Http2ConnectionErrorException(CoreStrings.HttpErrorConnectionSpecificHeaderField, Http2ErrorCode.PROTOCOL_ERROR, ConnectionEndReason.InvalidRequestHeaders); - } - // http://httpwg.org/specs/rfc7540.html#rfc.section.8.1.2 // A request or response containing uppercase header field names MUST be treated as malformed (Section 8.1.2.6). for (var i = 0; i < name.Length; i++) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index c47a175956b5..3dbf2222ed63 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -312,6 +312,15 @@ private void OnHeaderCore(HeaderType headerType, int? staticTableIndex, ReadOnly try { + // https://www.rfc-editor.org/rfc/rfc9114#section-4.2 + // Connection-specific header fields make a message malformed regardless of how the header name was + // encoded. A QPACK indexed-name representation (referencing a dynamic table entry inserted via the + // encoder stream) would otherwise let these fields bypass the check below, so validate every header type. + if (IsConnectionSpecificHeaderField(name, value)) + { + throw new Http3StreamErrorException(CoreStrings.HttpErrorConnectionSpecificHeaderField, Http3ErrorCode.MessageError); + } + if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) { // Just use name + value bytes and do full validation for request trailers. @@ -378,11 +387,6 @@ private void OnHeaderCore(HeaderType headerType, int? staticTableIndex, ReadOnly private void ValidateHeaderContent(ReadOnlySpan name, ReadOnlySpan value) { - if (IsConnectionSpecificHeaderField(name, value)) - { - throw new Http3StreamErrorException(CoreStrings.HttpErrorConnectionSpecificHeaderField, Http3ErrorCode.MessageError); - } - // http://httpwg.org/specs/rfc7540.html#rfc.section.8.1.2 // A request or response containing uppercase header field names MUST be treated as malformed (Section 8.1.2.6). for (var i = 0; i < name.Length; i++) diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs index ffc51544da3b..f096758d2ad1 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2ConnectionTests.cs @@ -3159,6 +3159,35 @@ public Task HEADERS_Received_HeaderBlockContainsConnectionSpecificHeader_Connect expectedEndReason: ConnectionEndReason.InvalidRequestHeaders); } + [Fact] + public async Task HEADERS_Received_TransferEncodingWithHPackIndexedName_ConnectionError() + { + await InitializeConnectionAsync(_noopApplication); + + // Reference the "transfer-encoding" name via HPACK's indexed-name representation (static table index 57) + // with a literal "chunked" value. This exercises the OnStaticIndexedHeader(index, value) path, which must + // still reject connection-specific header fields even though the name was not sent as a literal string. + var headerBlock = new byte[] + { + 0x82, // :method: GET (indexed, static index 2) + 0x84, // :path: / (indexed, static index 4) + 0x86, // :scheme: http (indexed, static index 6) + 0x0f, 0x2a, // Literal Header Field without Indexing - Indexed Name (static index 57: transfer-encoding) + 0x07, // value length: 7 + (byte)'c', (byte)'h', (byte)'u', (byte)'n', (byte)'k', (byte)'e', (byte)'d', + }; + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headerBlock); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: 1, + expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, + expectedErrorMessage: CoreStrings.HttpErrorConnectionSpecificHeaderField); + + AssertConnectionEndReason(ConnectionEndReason.InvalidRequestHeaders); + } + [Fact] public async Task HEADERS_Received_HeaderBlockContainsTEHeader_ValueIsTrailers_NoError() {