From a370b43cb33486c949a19e6f230367a6e0add8aa Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Mar 2026 19:14:10 +0100 Subject: [PATCH 1/4] timeout on trailer headers in http2 --- .../src/Internal/Http2/Http2Connection.cs | 17 +++ .../Http2/Http2TimeoutTests.cs | 138 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs index 2de5acdc91e0..36ae7f87a505 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs @@ -835,6 +835,18 @@ private Task ProcessHeadersFrameAsync(IHttpApplication appli _currentHeadersStream = stream; _requestHeaderParsingState = RequestHeaderParsingState.Trailers; + // Cancel keep-alive timeout and start header timeout if necessary. + if (!_incomingFrame.HeadersEndHeaders) + { + if (TimeoutControl.TimerReason != TimeoutReason.None) + { + Debug.Assert(TimeoutControl.TimerReason == TimeoutReason.KeepAlive, "Non keep-alive timeout set at start of trailer headers."); + TimeoutControl.CancelTimeout(); + } + + TimeoutControl.SetTimeout(Limits.RequestHeadersTimeout, TimeoutReason.RequestHeaders); + } + var headersPayload = payload.Slice(0, _incomingFrame.HeadersPayloadLength); // Minus padding return DecodeTrailersAsync(_incomingFrame.HeadersEndHeaders, headersPayload); } @@ -1195,6 +1207,11 @@ private Task ProcessContinuationFrameAsync(in ReadOnlySequence payload) if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) { + if (_incomingFrame.ContinuationEndHeaders) + { + TimeoutControl.CancelTimeout(); + } + return DecodeTrailersAsync(_incomingFrame.ContinuationEndHeaders, payload); } else diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs index 665fc0bcc09e..cb8ab4e6eaae 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs @@ -998,4 +998,142 @@ await WaitForConnectionErrorAsync( _mockTimeoutHandler.VerifyNoOtherCalls(); _mockConnectionContext.VerifyNoOtherCalls(); } + + [Fact] + public async Task HEADERS_TrailerWithoutEndHeaders_WithinRequestHeadersTimeout_AbortsConnection() + { + // Verifies that fragmented trailer HEADERS (without END_HEADERS) arm RequestHeadersTimeout, + // preventing connection indefinitely waiting for the final CONTINUATION. + var limits = _serviceContext.ServerOptions.Limits; + var requestBodyReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await InitializeConnectionAsync(async context => + { + var buffer = new byte[1024]; + // Read all request body data to keep the stream alive + while (await context.Request.Body.ReadAsync(buffer) > 0) { } + requestBodyReceived.TrySetResult(); + }); + + // Start a POST stream with headers complete + await StartStreamAsync(1, _postRequestHeaders, endStream: false); + + // Send body data + await SendDataAsync(1, _helloBytes, endStream: false); + + // Send trailer HEADERS with END_STREAM but NOT END_HEADERS (fragmented trailers) + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, new byte[0]); + + // Advance time past RequestHeadersTimeout + AdvanceTime(limits.RequestHeadersTimeout + Heartbeat.Interval); + + _mockTimeoutHandler.Verify(h => h.OnTimeout(It.IsAny()), Times.Never); + + // Send an empty CONTINUATION without END_HEADERS to trigger the tick + await SendEmptyContinuationFrameAsync(1, Http2ContinuationFrameFlags.NONE); + + AdvanceTime(TimeSpan.FromTicks(1)); + + _mockTimeoutHandler.Verify(h => h.OnTimeout(TimeoutReason.RequestHeaders), Times.Once); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: int.MaxValue, + Http2ErrorCode.INTERNAL_ERROR, + CoreStrings.BadRequest_RequestHeadersTimeout); + AssertConnectionEndReason(ConnectionEndReason.RequestHeadersTimeout); + + _mockConnectionContext.Verify(c => c.Abort(It.Is(e => + e.Message == CoreStrings.BadRequest_RequestHeadersTimeout)), Times.Once); + + _mockTimeoutHandler.VerifyNoOtherCalls(); + _mockConnectionContext.VerifyNoOtherCalls(); + } + + [Fact] + public async Task HEADERS_TrailerWithContinuationEndHeaders_CancelsRequestHeadersTimeout() + { + // Verifies that when fragmented trailers complete normally (CONTINUATION with END_HEADERS), + // the RequestHeadersTimeout is cancelled and the stream completes successfully. + await InitializeConnectionAsync(_readTrailersApplication); + + // Start a request stream + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + + // Send body data + await SendDataAsync(1, _helloBytes, endStream: false); + + // Send trailer HEADERS with END_STREAM but NOT END_HEADERS (fragmented) + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, new byte[0]); + + // Verify timeout was armed for the trailer header block + _mockTimeoutControl.Verify(c => c.SetTimeout(It.IsAny(), TimeoutReason.RequestHeaders), Times.Once); + + // Complete the trailer block with CONTINUATION + END_HEADERS + await SendContinuationAsync(1, Http2ContinuationFrameFlags.END_HEADERS, _requestTrailers); + + // Verify timeout was cancelled + _mockTimeoutControl.Verify(c => c.CancelTimeout(), Times.AtLeastOnce); + + // The stream should complete normally + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 36, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_TrailerWithEmptyCONTINUATIONs_WithinRequestHeadersTimeout_AbortsConnection() + { + // Verifies that sending trailer HEADERS without END_HEADERS followed by + // empty CONTINUATION frames (also without END_HEADERS) still triggers the timeout. + // This mirrors HEADERS_ReceivedWithoutAllCONTINUATIONs_WithinRequestHeadersTimeout_AbortsConnection + // but for trailers instead of initial request headers. + var limits = _serviceContext.ServerOptions.Limits; + + await InitializeConnectionAsync(async context => + { + var buffer = new byte[1024]; + while (await context.Request.Body.ReadAsync(buffer) > 0) { } + }); + + // Start a POST stream + await StartStreamAsync(1, _postRequestHeaders, endStream: false); + + // Send body data + await SendDataAsync(1, _helloBytes, endStream: false); + + // Send trailer HEADERS with END_STREAM but NOT END_HEADERS + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, new byte[0]); + + // Send empty continuation without END_HEADERS + await SendEmptyContinuationFrameAsync(1, Http2ContinuationFrameFlags.NONE); + + // Not yet timed out + AdvanceTime(limits.RequestHeadersTimeout + Heartbeat.Interval); + + _mockTimeoutHandler.Verify(h => h.OnTimeout(It.IsAny()), Times.Never); + + // Send another empty continuation to trigger the next tick + await SendEmptyContinuationFrameAsync(1, Http2ContinuationFrameFlags.NONE); + + AdvanceTime(TimeSpan.FromTicks(1)); + + _mockTimeoutHandler.Verify(h => h.OnTimeout(TimeoutReason.RequestHeaders), Times.Once); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: int.MaxValue, + Http2ErrorCode.INTERNAL_ERROR, + CoreStrings.BadRequest_RequestHeadersTimeout); + AssertConnectionEndReason(ConnectionEndReason.RequestHeadersTimeout); + + _mockConnectionContext.Verify(c => c.Abort(It.Is(e => + e.Message == CoreStrings.BadRequest_RequestHeadersTimeout)), Times.Once); + + _mockTimeoutHandler.VerifyNoOtherCalls(); + _mockConnectionContext.VerifyNoOtherCalls(); + } } From 0f9e153dbd6622445a798de9cf8b1bbc8162e183 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 24 Mar 2026 19:14:24 +0100 Subject: [PATCH 2/4] dont write ultra nit comment --- .../test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs index cb8ab4e6eaae..6ab8126bc03a 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs @@ -1089,8 +1089,6 @@ public async Task HEADERS_TrailerWithEmptyCONTINUATIONs_WithinRequestHeadersTime { // Verifies that sending trailer HEADERS without END_HEADERS followed by // empty CONTINUATION frames (also without END_HEADERS) still triggers the timeout. - // This mirrors HEADERS_ReceivedWithoutAllCONTINUATIONs_WithinRequestHeadersTimeout_AbortsConnection - // but for trailers instead of initial request headers. var limits = _serviceContext.ServerOptions.Limits; await InitializeConnectionAsync(async context => From 76bcc145ff076008bb435fefd562e61449824e69 Mon Sep 17 00:00:00 2001 From: Dmitrii Korolev Date: Thu, 9 Apr 2026 17:34:58 +0200 Subject: [PATCH 3/4] cleanup --- .../test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs index 6ab8126bc03a..2e369d87aa02 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TimeoutTests.cs @@ -1005,14 +1005,12 @@ public async Task HEADERS_TrailerWithoutEndHeaders_WithinRequestHeadersTimeout_A // Verifies that fragmented trailer HEADERS (without END_HEADERS) arm RequestHeadersTimeout, // preventing connection indefinitely waiting for the final CONTINUATION. var limits = _serviceContext.ServerOptions.Limits; - var requestBodyReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await InitializeConnectionAsync(async context => { var buffer = new byte[1024]; // Read all request body data to keep the stream alive while (await context.Request.Body.ReadAsync(buffer) > 0) { } - requestBodyReceived.TrySetResult(); }); // Start a POST stream with headers complete From fa90e59d2590e7d4f3c021d9269b1f0423b8cdf5 Mon Sep 17 00:00:00 2001 From: Korolev Dmitry Date: Tue, 14 Apr 2026 13:22:28 +0200 Subject: [PATCH 4/4] http3 as well --- .../src/Internal/Http3/Http3Connection.cs | 2 +- .../src/Internal/Http3/Http3ControlStream.cs | 1 + .../Core/src/Internal/Http3/Http3Stream.cs | 6 +- .../Core/src/Internal/Http3/IHttp3Stream.cs | 5 + .../shared/test/Http3/Http3InMemory.cs | 13 +++ .../Http3/Http3TimeoutTests.cs | 101 ++++++++++++++++++ 6 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs index e5defa438830..2ab4c675b65c 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Connection.cs @@ -281,7 +281,7 @@ private void UpdateStreamTimeouts(long timestamp) { foreach (var stream in _streams.Values) { - if (stream.IsReceivingHeader) + if (stream.IsReceivingHeader || stream.IsReceivingTrailerHeaders) { if (stream.StreamTimeoutTimestamp == default) { diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs index 1e0c5821d3df..f1952b2dd2aa 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs @@ -83,6 +83,7 @@ private void OnStreamClosed() public long StreamTimeoutTimestamp { get; set; } public bool IsReceivingHeader => _headerType == -1; + public bool IsReceivingTrailerHeaders => false; public bool IsDraining => false; public bool IsRequestStream => false; public string TraceIdentifier => _context.StreamContext.ConnectionId; diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index 7ada6890f867..e8c4c1aa7fa6 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -88,6 +88,7 @@ public bool ReceivedEmptyRequestBody public long StreamId => _streamIdFeature.StreamId; public long StreamTimeoutTimestamp { get; set; } public bool IsReceivingHeader => _requestHeaderParsingState <= RequestHeaderParsingState.Headers; // Assigned once headers are received + public bool IsReceivingTrailerHeaders { get; private set; } public bool IsDraining => _appCompletedTaskSource.GetStatus() != ValueTaskSourceStatus.Pending; // Draining starts once app is complete public bool IsRequestStream => true; public BaseConnectionContext ConnectionContext => _context.ConnectionContext; @@ -115,6 +116,7 @@ public void Initialize(Http3StreamContext context) _eagerRequestHeadersParsedLimit = ServerOptions.Limits.MaxRequestHeaderCount * 2; _isMethodConnect = false; _completionState = default; + IsReceivingTrailerHeaders = false; StreamTimeoutTimestamp = 0; if (_frameWriter == null) @@ -823,10 +825,12 @@ private async Task ProcessHeadersFrameAsync(IHttpApplication if (endHeaders) { QPackDecoder.Reset(); + IsReceivingTrailerHeaders = false; } else { - // Headers frame isn't complete, return to read more of the frame + // Headers frame isn't complete, start trailer header timeout and return to read more of the frame. + IsReceivingTrailerHeaders = true; return; } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs index 24a61c4b7885..14a39d0febc4 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs @@ -29,6 +29,11 @@ internal interface IHttp3Stream /// bool IsReceivingHeader { get; } + /// + /// The stream is receiving a trailer HEADERS frame that spans multiple reads. + /// + bool IsReceivingTrailerHeaders { get; } + /// /// The stream request delegate is complete and the transport is draining. /// diff --git a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs index e6330862c905..cad8d690f4fb 100644 --- a/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs +++ b/src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs @@ -746,6 +746,19 @@ internal async Task SendHeadersPartialAsync() await SendAsync(Span.Empty); } + /// + /// Sends a trailer HEADERS frame whose declared payload length is larger than the bytes + /// actually written, simulating a fragmented trailer HEADERS frame that spans multiple reads. + /// The frame header declares bytes, but only one byte is sent. + /// + internal async Task SendTrailerHeadersPartialAsync(int declaredLength = 100) + { + var outputWriter = Pair.Application.Output; + Http3FrameWriter.WriteHeader(Http3FrameType.Headers, frameLength: declaredLength, outputWriter); + // Send a single byte so the frame reader parses the header and enters isContinuedFrame mode. + await SendAsync([ 0x00 ]); + } + internal async Task SendDataAsync(Memory data, bool endStream = false) { await SendFrameAsync(Http3FrameType.Data, data, endStream); diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs index 4d9d5a367bb8..e6f1690ecbd9 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http3/Http3TimeoutTests.cs @@ -728,4 +728,105 @@ await Http3Api.InitializeConnectionAsync(context => _mockTimeoutHandler.VerifyNoOtherCalls(); } + + [Fact] + public async Task HEADERS_TrailerIncompleteFrameReceivedWithinRequestHeadersTimeout_StreamError() + { + // Verifies that a fragmented trailer HEADERS frame (payload spans multiple reads) + // arms RequestHeadersTimeout, preventing the stream from hanging indefinitely. + var postRequestHeaders = new[] + { + new KeyValuePair(InternalHeaderNames.Method, "POST"), + new KeyValuePair(InternalHeaderNames.Path, "/"), + new KeyValuePair(InternalHeaderNames.Scheme, "http"), + new KeyValuePair(InternalHeaderNames.Authority, "localhost:80"), + }; + + var timeProvider = _serviceContext.FakeTimeProvider; + var limits = _serviceContext.ServerOptions.Limits; + + await Http3Api.InitializeConnectionAsync(async context => + { + // Read the request body to keep the stream alive + var buffer = new byte[1024]; + while (await context.Request.Body.ReadAsync(buffer) > 0) { } + }).DefaultTimeout(); + + await Http3Api.CreateControlStream(); + var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); + await controlStream.ExpectSettingsAsync().DefaultTimeout(); + + var requestStream = await Http3Api.CreateRequestStream(postRequestHeaders, endStream: false); + + await requestStream.OnHeaderReceivedTask.DefaultTimeout(); + + // Send some body data + await requestStream.SendDataAsync(_helloWorldBytes.AsMemory(), endStream: false); + + // Send a partial trailer HEADERS frame (declares 100 bytes, only sends 1) + await requestStream.SendTrailerHeadersPartialAsync(); + + var serverRequestStream = Http3Api.Connection._streams[requestStream.StreamId]; + + // The stream should now have IsReceivingTrailerHeaders = true + Http3Api.TriggerTick(); + Http3Api.TriggerTick(limits.RequestHeadersTimeout); + + Http3Api.TriggerTick(TimeSpan.FromTicks(1)); + + await requestStream.WaitForStreamErrorAsync( + Http3ErrorCode.RequestRejected, + AssertExpectedErrorMessages, + CoreStrings.BadRequest_RequestHeadersTimeout); + } + + [Fact] + public async Task HEADERS_TrailerCompleteFrameReceivedWithinRequestHeadersTimeout_Success() + { + // Verifies that a complete (non-fragmented) trailer HEADERS frame does not trigger + // a timeout and the stream completes successfully. + var postRequestHeaders = new[] + { + new KeyValuePair(InternalHeaderNames.Method, "POST"), + new KeyValuePair(InternalHeaderNames.Path, "/"), + new KeyValuePair(InternalHeaderNames.Scheme, "http"), + new KeyValuePair(InternalHeaderNames.Authority, "localhost:80"), + }; + + var requestReceivedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await Http3Api.InitializeConnectionAsync(async context => + { + var buffer = new byte[1024]; + while (await context.Request.Body.ReadAsync(buffer) > 0) { } + requestReceivedTcs.SetResult(); + }).DefaultTimeout(); + + await Http3Api.CreateControlStream(); + var controlStream = await Http3Api.GetInboundControlStream().DefaultTimeout(); + await controlStream.ExpectSettingsAsync().DefaultTimeout(); + + var requestStream = await Http3Api.CreateRequestStream(postRequestHeaders, endStream: false); + + await requestStream.OnHeaderReceivedTask.DefaultTimeout(); + + // Send body data + await requestStream.SendDataAsync(_helloWorldBytes.AsMemory(), endStream: false); + + // Send a complete trailer HEADERS frame with endStream by completing the transport + var trailerHeaders = new[] + { + new KeyValuePair("x-trailer", "value"), + }; + await requestStream.SendHeadersAsync(trailerHeaders, endStream: true); + + // Wait for the request delegate to complete + await requestReceivedTcs.Task.DefaultTimeout(); + + await requestStream.ExpectHeadersAsync(); + await requestStream.ExpectReceiveEndOfStream(); + + // No timeout should have fired + Http3Api.TriggerTick(_serviceContext.ServerOptions.Limits.RequestHeadersTimeout + TimeSpan.FromTicks(1)); + } }