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
17 changes: 17 additions & 0 deletions src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,18 @@ private Task ProcessHeadersFrameAsync<TContext>(IHttpApplication<TContext> 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);
}
Expand Down Expand Up @@ -1195,6 +1207,11 @@ private Task ProcessContinuationFrameAsync(in ReadOnlySequence<byte> payload)

if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers)
{
if (_incomingFrame.ContinuationEndHeaders)
{
TimeoutControl.CancelTimeout();
}

return DecodeTrailersAsync(_incomingFrame.ContinuationEndHeaders, payload);
}
else
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -823,10 +825,12 @@ private async Task ProcessHeadersFrameAsync<TContext>(IHttpApplication<TContext>
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;
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/Servers/Kestrel/Core/src/Internal/Http3/IHttp3Stream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ internal interface IHttp3Stream
/// </summary>
bool IsReceivingHeader { get; }

/// <summary>
/// The stream is receiving a trailer HEADERS frame that spans multiple reads.
/// </summary>
bool IsReceivingTrailerHeaders { get; }

/// <summary>
/// The stream request delegate is complete and the transport is draining.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Servers/Kestrel/shared/test/Http3/Http3InMemory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,19 @@ internal async Task SendHeadersPartialAsync()
await SendAsync(Span<byte>.Empty);
}

/// <summary>
/// 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 <paramref name="declaredLength"/> bytes, but only one byte is sent.
/// </summary>
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<byte> data, bool endStream = false)
{
await SendFrameAsync(Http3FrameType.Data, data, endStream);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -998,4 +998,138 @@ await WaitForConnectionErrorAsync<ConnectionAbortedException>(
_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;

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) { }
});

// 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<TimeoutReason>()), 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<Microsoft.AspNetCore.Http.BadHttpRequestException>(
ignoreNonGoAwayFrames: false,
expectedLastStreamId: int.MaxValue,
Http2ErrorCode.INTERNAL_ERROR,
CoreStrings.BadRequest_RequestHeadersTimeout);
AssertConnectionEndReason(ConnectionEndReason.RequestHeadersTimeout);

_mockConnectionContext.Verify(c => c.Abort(It.Is<ConnectionAbortedException>(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<TimeSpan>(), 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.
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<TimeoutReason>()), 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<Microsoft.AspNetCore.Http.BadHttpRequestException>(
ignoreNonGoAwayFrames: false,
expectedLastStreamId: int.MaxValue,
Http2ErrorCode.INTERNAL_ERROR,
CoreStrings.BadRequest_RequestHeadersTimeout);
AssertConnectionEndReason(ConnectionEndReason.RequestHeadersTimeout);

_mockConnectionContext.Verify(c => c.Abort(It.Is<ConnectionAbortedException>(e =>
e.Message == CoreStrings.BadRequest_RequestHeadersTimeout)), Times.Once);

_mockTimeoutHandler.VerifyNoOtherCalls();
_mockConnectionContext.VerifyNoOtherCalls();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>(InternalHeaderNames.Method, "POST"),
new KeyValuePair<string, string>(InternalHeaderNames.Path, "/"),
new KeyValuePair<string, string>(InternalHeaderNames.Scheme, "http"),
new KeyValuePair<string, string>(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<string, string>(InternalHeaderNames.Method, "POST"),
new KeyValuePair<string, string>(InternalHeaderNames.Path, "/"),
new KeyValuePair<string, string>(InternalHeaderNames.Scheme, "http"),
new KeyValuePair<string, string>(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<string, string>("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));
}
}
Loading