Skip to content

Commit a916768

Browse files
author
ladeak
committed
Adding MaxResponseHeadersLimit to H2 framewriter
1 parent 196063f commit a916768

File tree

11 files changed

+365
-170
lines changed

11 files changed

+365
-170
lines changed

src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Buffers.Binary;
66
using System.Diagnostics;
77
using System.IO.Pipelines;
8+
using System.Net.Http;
89
using System.Net.Http.HPack;
910
using System.Threading.Channels;
1011
using Microsoft.AspNetCore.Connections;
@@ -73,6 +74,8 @@ internal sealed class Http2FrameWriter
7374

7475
private int _maxFrameSize = Http2PeerSettings.MinAllowedMaxFrameSize;
7576
private readonly ArrayBufferWriter<byte> _headerEncodingBuffer;
77+
private readonly int? _maxResponseHeadersTotalSize;
78+
private int _currentResponseHeadersTotalSize;
7679
private long _unflushedBytes;
7780

7881
private bool _completed;
@@ -110,7 +113,7 @@ public Http2FrameWriter(
110113
_headerEncodingBuffer = new ArrayBufferWriter<byte>(_maxFrameSize);
111114

112115
_scheduleInline = serviceContext.Scheduler == PipeScheduler.Inline;
113-
116+
_maxResponseHeadersTotalSize = _http2Connection.Limits.MaxResponseHeadersTotalSize;
114117
_hpackEncoder = new DynamicHPackEncoder(serviceContext.ServerOptions.AllowResponseHeaderCompression);
115118

116119
_maximumFlowControlQueueSize = AppContextMaximumFlowControlQueueSize is null
@@ -484,25 +487,32 @@ private void WriteResponseHeadersUnsynchronized(int streamId, int statusCode, Ht
484487
{
485488
try
486489
{
490+
// In the case of the headers, there is always a status header to be returned, so BeginEncodeHeaders will not return BufferTooSmall.
487491
_headersEnumerator.Initialize(headers);
488492
_outgoingFrame.PrepareHeaders(headerFrameFlags, streamId);
489493
_headerEncodingBuffer.ResetWrittenCount();
490494
var buffer = _headerEncodingBuffer.GetSpan(_maxFrameSize)[0.._maxFrameSize]; // GetSpan might return more data that can result in a less deterministic behavior on the way headers are split into frames.
491495
var done = HPackHeaderWriter.BeginEncodeHeaders(statusCode, _hpackEncoder, _headersEnumerator, buffer, out var payloadLength);
492496
Debug.Assert(done != HPackHeaderWriter.HeaderWriteResult.BufferTooSmall, "Oversized frames should not be returned, beucase this always writes the status.");
497+
if (_maxResponseHeadersTotalSize.HasValue && payloadLength > _maxResponseHeadersTotalSize.Value)
498+
{
499+
ThrowResponseHeadersLimitException();
500+
}
501+
_currentResponseHeadersTotalSize = payloadLength;
493502
if (done == HPackHeaderWriter.HeaderWriteResult.Done)
494503
{
495-
// Fast path
504+
// Fast path, only a single HEADER frame.
496505
_outgoingFrame.PayloadLength = payloadLength;
497506
_outgoingFrame.HeadersFlags |= Http2HeadersFrameFlags.END_HEADERS;
498507
WriteHeaderUnsynchronized();
499508
_outputWriter.Write(buffer[0..payloadLength]);
500509
}
501510
else
502511
{
503-
// Slow path
512+
// More headers sent in CONTINUATION frames.
504513
_headerEncodingBuffer.Advance(payloadLength);
505-
FinishWritingHeaders(streamId, payloadLength, done);
514+
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: true);
515+
FinishWritingHeaders(streamId);
506516
}
507517
}
508518
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state.
@@ -540,19 +550,46 @@ private ValueTask<FlushResult> WriteDataAndTrailersAsync(Http2Stream stream, in
540550

541551
try
542552
{
553+
// In the case of the trailers, there is no status header to be written, so even the first call to BeginEncodeHeaders can return BufferTooSmall.
543554
_outgoingFrame.PrepareHeaders(Http2HeadersFrameFlags.END_STREAM, streamId);
544-
var done = HPackHeaderWriter.HeaderWriteResult.MoreHeaders;
545-
int payloadLength;
555+
var bufferSize = _headerEncodingBuffer.Capacity;
556+
HPackHeaderWriter.HeaderWriteResult done;
546557
do
547558
{
548559
_headersEnumerator.Initialize(headers);
549560
_headerEncodingBuffer.ResetWrittenCount();
550-
var bufferSize = done == HPackHeaderWriter.HeaderWriteResult.BufferTooSmall ? _headerEncodingBuffer.Capacity * 2 : _headerEncodingBuffer.Capacity;
551561
var buffer = _headerEncodingBuffer.GetSpan(bufferSize)[0..bufferSize]; // GetSpan might return more data that can result in a less deterministic behavior on the way headers are split into frames.
552-
done = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength);
562+
done = HPackHeaderWriter.BeginEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out var payloadLength);
563+
if (done == HPackHeaderWriter.HeaderWriteResult.Done)
564+
{
565+
if (_maxResponseHeadersTotalSize.HasValue && _currentResponseHeadersTotalSize + payloadLength > _maxResponseHeadersTotalSize.Value)
566+
{
567+
ThrowResponseHeadersLimitException();
568+
}
569+
_headerEncodingBuffer.Advance(payloadLength);
570+
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: true);
571+
}
572+
else if (done == HPackHeaderWriter.HeaderWriteResult.MoreHeaders)
573+
{
574+
// More headers sent in CONTINUATION frames.
575+
_currentResponseHeadersTotalSize += payloadLength;
576+
if (_maxResponseHeadersTotalSize.HasValue && _currentResponseHeadersTotalSize > _maxResponseHeadersTotalSize.Value)
577+
{
578+
ThrowResponseHeadersLimitException();
579+
}
580+
_headerEncodingBuffer.Advance(payloadLength);
581+
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: true);
582+
FinishWritingHeaders(streamId);
583+
}
584+
else
585+
{
586+
if (_maxResponseHeadersTotalSize.HasValue && _currentResponseHeadersTotalSize + bufferSize > _maxResponseHeadersTotalSize.Value)
587+
{
588+
ThrowResponseHeadersLimitException();
589+
}
590+
bufferSize *= 2;
591+
}
553592
} while (done == HPackHeaderWriter.HeaderWriteResult.BufferTooSmall);
554-
_headerEncodingBuffer.Advance(payloadLength);
555-
FinishWritingHeaders(streamId, payloadLength, done);
556593
}
557594
// Any exception from the HPack encoder can leave the dynamic table in a corrupt state.
558595
// Since we allow custom header encoders we don't know what type of exceptions to expect.
@@ -594,18 +631,36 @@ private void SplitHeaderFramesToOutput(int streamId, HPackHeaderWriter.HeaderWri
594631
}
595632
}
596633

597-
private void FinishWritingHeaders(int streamId, int payloadLength, HPackHeaderWriter.HeaderWriteResult done)
634+
private void FinishWritingHeaders(int streamId)
598635
{
599-
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: true);
600-
while (done != HPackHeaderWriter.HeaderWriteResult.Done)
636+
HPackHeaderWriter.HeaderWriteResult done;
637+
var bufferSize = _headerEncodingBuffer.Capacity;
638+
do
601639
{
602640
_headerEncodingBuffer.ResetWrittenCount();
603-
var bufferSize = done == HPackHeaderWriter.HeaderWriteResult.BufferTooSmall ? _headerEncodingBuffer.Capacity * 2 : _headerEncodingBuffer.Capacity;
604641
var buffer = _headerEncodingBuffer.GetSpan(bufferSize)[0..bufferSize];
605-
done = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength);
606-
_headerEncodingBuffer.Advance(payloadLength);
607-
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: false);
608-
}
642+
done = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out var payloadLength);
643+
644+
if (done == HPackHeaderWriter.HeaderWriteResult.BufferTooSmall)
645+
{
646+
if (_maxResponseHeadersTotalSize.HasValue && _currentResponseHeadersTotalSize + bufferSize > _maxResponseHeadersTotalSize.Value)
647+
{
648+
ThrowResponseHeadersLimitException();
649+
}
650+
bufferSize *= 2;
651+
}
652+
else
653+
{
654+
// In case of Done or MoreHeaders: write to output.
655+
_currentResponseHeadersTotalSize += payloadLength;
656+
if (_maxResponseHeadersTotalSize.HasValue && _currentResponseHeadersTotalSize > _maxResponseHeadersTotalSize.Value)
657+
{
658+
ThrowResponseHeadersLimitException();
659+
}
660+
_headerEncodingBuffer.Advance(payloadLength);
661+
SplitHeaderFramesToOutput(streamId, done, isFramePrepared: false);
662+
}
663+
} while (done != HPackHeaderWriter.HeaderWriteResult.Done);
609664
}
610665

611666
/* Padding is not implemented
@@ -1031,4 +1086,6 @@ private void EnqueueWaitingForMoreConnectionWindow(Http2OutputProducer producer)
10311086
_http2Connection.Abort(new ConnectionAbortedException("HTTP/2 connection exceeded the outgoing flow control maximum queue size."));
10321087
}
10331088
}
1089+
1090+
private void ThrowResponseHeadersLimitException() => throw new HPackEncodingException(SR.Format(SR.net_http_headers_exceeded_length, _maxResponseHeadersTotalSize!));
10341091
}

src/Servers/Kestrel/Core/src/KestrelServerLimits.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ public class KestrelServerLimits
1515
// Matches the non-configurable default response buffer size for Kestrel in 1.0.0
1616
private long? _maxResponseBufferSize = 64 * 1024;
1717

18+
// Matches the HttpClientHandler.MaxResponseHeadersLength's response header size.
19+
private int? _maxResponseHeadersTotalSize = 64 * 1024;
20+
1821
// Matches the default client_max_body_size in nginx.
1922
// Also large enough that most requests should be under the limit.
2023
private long? _maxRequestBufferSize = 1024 * 1024;
@@ -256,6 +259,27 @@ public long? MaxConcurrentUpgradedConnections
256259
}
257260
}
258261

262+
/// <summary>
263+
/// Gets or sets the maximum size of the total response headers. When set to null, the response headers total size is unlimited.
264+
/// Defaults to 65,536 bytes (64 KB).
265+
/// </summary>
266+
/// <remarks>
267+
/// When set to null, the size of the response buffer is unlimited.
268+
/// When set to zero, no headers are allowed to be returned.
269+
/// </remarks>
270+
public int? MaxResponseHeadersTotalSize
271+
{
272+
get => _maxResponseHeadersTotalSize;
273+
set
274+
{
275+
if (value.HasValue && value.Value < 0)
276+
{
277+
throw new ArgumentOutOfRangeException(nameof(value), CoreStrings.NonNegativeNumberOrNullRequired);
278+
}
279+
_maxResponseHeadersTotalSize = value;
280+
}
281+
}
282+
259283
internal void Serialize(Utf8JsonWriter writer)
260284
{
261285
writer.WriteString(nameof(KeepAliveTimeout), KeepAliveTimeout.ToString());
@@ -323,6 +347,16 @@ internal void Serialize(Utf8JsonWriter writer)
323347
writer.WriteString(nameof(MinResponseDataRate), MinResponseDataRate?.ToString());
324348
writer.WriteString(nameof(RequestHeadersTimeout), RequestHeadersTimeout.ToString());
325349

350+
writer.WritePropertyName(nameof(MaxResponseHeadersTotalSize));
351+
if (MaxResponseHeadersTotalSize is null)
352+
{
353+
writer.WriteNullValue();
354+
}
355+
else
356+
{
357+
writer.WriteNumberValue(MaxResponseHeadersTotalSize.Value);
358+
}
359+
326360
// HTTP2
327361
writer.WritePropertyName(nameof(Http2));
328362
writer.WriteStartObject();
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerLimits.MaxResponseHeadersTotalSize.get -> int?
3+
Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerLimits.MaxResponseHeadersTotalSize.set -> void

src/Servers/Kestrel/Core/test/Http2/Http2FrameWriterTests.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
using Microsoft.AspNetCore.InternalTesting;
1313
using Moq;
1414
using Xunit;
15+
using Microsoft.AspNetCore.Http.Features;
16+
using Castle.Core;
17+
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
1518

1619
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests;
1720

@@ -56,7 +59,16 @@ public async Task WriteWindowUpdate_UnsetsReservedBit()
5659
private Http2FrameWriter CreateFrameWriter(Pipe pipe)
5760
{
5861
var serviceContext = TestContextFactory.CreateServiceContext(new KestrelServerOptions());
59-
return new Http2FrameWriter(pipe.Writer, null, null, 1, null, null, null, _dirtyMemoryPool, serviceContext);
62+
var featureCollection = new FeatureCollection();
63+
featureCollection.Set<IConnectionMetricsContextFeature>(new TestConnectionMetricsContextFeature());
64+
var connectionContext = TestContextFactory.CreateHttpConnectionContext(
65+
serviceContext: serviceContext,
66+
connectionContext: null,
67+
transport: new DuplexPipe(pipe.Reader, pipe.Writer),
68+
connectionFeatures: featureCollection);
69+
70+
var http2Connection = new Http2Connection(connectionContext);
71+
return new Http2FrameWriter(pipe.Writer, null, http2Connection, 1, null, null, null, _dirtyMemoryPool, serviceContext);
6072
}
6173

6274
[Fact]
@@ -92,6 +104,11 @@ public async Task WriteHeader_UnsetsReservedBit()
92104

93105
Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, payload.Skip(5).Take(4).ToArray());
94106
}
107+
108+
private sealed class TestConnectionMetricsContextFeature : IConnectionMetricsContextFeature
109+
{
110+
public ConnectionMetricsContext MetricsContext { get; }
111+
}
95112
}
96113

97114
public static class PipeReaderExtensions

src/Servers/Kestrel/perf/Microbenchmarks/Http2/Http2FrameWriterBenchmark.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2;
1212
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.FlowControl;
1313
using Microsoft.AspNetCore.InternalTesting;
14+
using Microsoft.AspNetCore.Http.Features;
15+
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure;
1416

1517
namespace Microsoft.AspNetCore.Server.Kestrel.Microbenchmarks;
1618

@@ -34,10 +36,19 @@ public void GlobalSetup()
3436
httpParser: new HttpParser<Http1ParsingHandler>(),
3537
dateHeaderValueManager: new DateHeaderValueManager(TimeProvider.System));
3638

39+
var featureCollection = new FeatureCollection();
40+
featureCollection.Set<IConnectionMetricsContextFeature>(new TestConnectionMetricsContextFeature());
41+
var connectionContext = TestContextFactory.CreateHttpConnectionContext(
42+
serviceContext: serviceContext,
43+
connectionContext: null,
44+
transport: new DuplexPipe(_pipe.Reader, _pipe.Writer),
45+
connectionFeatures: featureCollection);
46+
var http2Connection = new Http2Connection(connectionContext);
47+
3748
_frameWriter = new Http2FrameWriter(
3849
new NullPipeWriter(),
3950
connectionContext: null,
40-
http2Connection: null,
51+
http2Connection: http2Connection,
4152
maxStreamsPerConnection: 1,
4253
timeoutControl: null,
4354
minResponseDataRate: null,
@@ -63,4 +74,9 @@ public void Dispose()
6374
_pipe.Writer.Complete();
6475
_memoryPool?.Dispose();
6576
}
77+
78+
private sealed class TestConnectionMetricsContextFeature : IConnectionMetricsContextFeature
79+
{
80+
public ConnectionMetricsContext MetricsContext { get; }
81+
}
6682
}

src/Servers/Kestrel/samples/Http2SampleApp/Http2SampleApp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
<ItemGroup>
1010
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
1111
<Reference Include="Microsoft.Extensions.Logging.Console" />
12+
<Reference Include="Microsoft.AspNetCore" />
1213
</ItemGroup>
1314

1415
</Project>

0 commit comments

Comments
 (0)