diff --git a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs index 31a0dd7893669..ec5c7a022c65a 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackConnection.cs @@ -14,7 +14,7 @@ namespace System.Net.Test.Common { - internal sealed class Http3LoopbackConnection : GenericLoopbackConnection + public sealed class Http3LoopbackConnection : GenericLoopbackConnection { public const long H3_NO_ERROR = 0x100; public const long H3_GENERAL_PROTOCOL_ERROR = 0x101; @@ -188,11 +188,11 @@ public async Task AcceptRequestStreamAsync() return (controlStream, requestStream); } - public async Task EstablishControlStreamAsync() + public async Task EstablishControlStreamAsync(SettingsEntry[] settingsEntries) { _outboundControlStream = await OpenUnidirectionalStreamAsync(); await _outboundControlStream.SendUnidirectionalStreamTypeAsync(Http3LoopbackStream.ControlStream); - await _outboundControlStream.SendSettingsFrameAsync(); + await _outboundControlStream.SendSettingsFrameAsync(settingsEntries); } public override async Task ReadRequestBodyAsync() diff --git a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackServer.cs index 187490932cfa0..0c0abfcb094b4 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackServer.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackServer.cs @@ -66,12 +66,12 @@ public override void Dispose() _cert.Dispose(); } - private async Task EstablishHttp3ConnectionAsync() + private async Task EstablishHttp3ConnectionAsync(params SettingsEntry[] settingsEntries) { QuicConnection con = await _listener.AcceptConnectionAsync().ConfigureAwait(false); Http3LoopbackConnection connection = new Http3LoopbackConnection(con); - await connection.EstablishControlStreamAsync(); + await connection.EstablishControlStreamAsync(settingsEntries); return connection; } @@ -80,6 +80,11 @@ public override async Task EstablishGenericConnection return await EstablishHttp3ConnectionAsync(); } + public Task EstablishConnectionAsync(params SettingsEntry[] settingsEntries) + { + return EstablishHttp3ConnectionAsync(settingsEntries); + } + public override async Task AcceptConnectionAsync(Func funcAsync) { await using Http3LoopbackConnection con = await EstablishHttp3ConnectionAsync().ConfigureAwait(false); diff --git a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackStream.cs b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackStream.cs index 17c3d8e842832..14e4a43e66de1 100644 --- a/src/libraries/Common/tests/System/Net/Http/Http3LoopbackStream.cs +++ b/src/libraries/Common/tests/System/Net/Http/Http3LoopbackStream.cs @@ -14,8 +14,7 @@ namespace System.Net.Test.Common { - - internal sealed class Http3LoopbackStream : IAsyncDisposable + public sealed class Http3LoopbackStream : IAsyncDisposable { private const int MaximumVarIntBytes = 8; private const long VarIntMax = (1L << 62) - 1; @@ -58,18 +57,16 @@ public async Task SendUnidirectionalStreamTypeAsync(long streamType) await _stream.WriteAsync(buffer.AsMemory(0, bytesWritten)).ConfigureAwait(false); } - public async Task SendSettingsFrameAsync(ICollection<(long settingId, long settingValue)> settings = null) + public async Task SendSettingsFrameAsync(SettingsEntry[] settingsEntries) { - settings ??= Array.Empty<(long settingId, long settingValue)>(); - - var buffer = new byte[settings.Count * MaximumVarIntBytes * 2]; + var buffer = new byte[settingsEntries.Length * MaximumVarIntBytes * 2]; int bytesWritten = 0; - foreach ((long settingId, long settingValue) in settings) + foreach (SettingsEntry setting in settingsEntries) { - bytesWritten += EncodeHttpInteger(settingId, buffer.AsSpan(bytesWritten)); - bytesWritten += EncodeHttpInteger(settingValue, buffer.AsSpan(bytesWritten)); + bytesWritten += EncodeHttpInteger((int)setting.SettingId, buffer.AsSpan(bytesWritten)); + bytesWritten += EncodeHttpInteger(setting.Value, buffer.AsSpan(bytesWritten)); } await SendFrameAsync(SettingsFrame, buffer.AsMemory(0, bytesWritten)).ConfigureAwait(false); diff --git a/src/libraries/System.Net.Http/src/Resources/Strings.resx b/src/libraries/System.Net.Http/src/Resources/Strings.resx index 7b692d3f37c3f..8cd19984d7216 100644 --- a/src/libraries/System.Net.Http/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Http/src/Resources/Strings.resx @@ -357,6 +357,9 @@ The buffer was not long enough. + + The HTTP request headers length exceeded the server limit of {0} bytes. + The HTTP response headers length exceeded the set limit of {0} bytes. diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs index e513bce81b0af..45dc67d7342c5 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs @@ -56,6 +56,10 @@ internal sealed partial class Http2Connection : HttpConnectionBase private readonly Channel _writeChannel; private bool _lastPendingWriterShouldFlush; + // Server-advertised SETTINGS_MAX_HEADER_LIST_SIZE + // https://www.rfc-editor.org/rfc/rfc9113.html#section-6.5.2-2.12.1 + private uint _maxHeaderListSize = uint.MaxValue; // Defaults to infinite + // This flag indicates that the connection is shutting down and cannot accept new requests, because of one of the following conditions: // (1) We received a GOAWAY frame from the server // (2) We have exhaustead StreamIds (i.e. _nextStream == MaxStreamId) @@ -162,6 +166,14 @@ public Http2Connection(HttpConnectionPool pool, Stream stream) _nextPingRequestTimestamp = Environment.TickCount64 + _keepAlivePingDelay; _keepAlivePingPolicy = _pool.Settings._keepAlivePingPolicy; + uint maxHeaderListSize = _pool._lastSeenHttp2MaxHeaderListSize; + if (maxHeaderListSize > 0) + { + // Previous connections to the same host advertised a limit. + // Use this as an initial value before we receive the SETTINGS frame. + _maxHeaderListSize = maxHeaderListSize; + } + if (HttpTelemetry.Log.IsEnabled()) { HttpTelemetry.Log.Http20ConnectionEstablished(); @@ -822,6 +834,8 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f uint settingValue = BinaryPrimitives.ReadUInt32BigEndian(settings); settings = settings.Slice(4); + if (NetEventSource.Log.IsEnabled()) Trace($"Applying setting {(SettingId)settingId}={settingValue}"); + switch ((SettingId)settingId) { case SettingId.MaxConcurrentStreams: @@ -861,6 +875,11 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f } break; + case SettingId.MaxHeaderListSize: + _maxHeaderListSize = settingValue; + _pool._lastSeenHttp2MaxHeaderListSize = _maxHeaderListSize; + break; + default: // All others are ignored because we don't care about them. // Note, per RFC, unknown settings IDs should be ignored. @@ -1379,14 +1398,18 @@ private void WriteBytes(ReadOnlySpan bytes, ref ArrayBuffer headerBuffer) headerBuffer.Commit(bytes.Length); } - private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders headers, ref ArrayBuffer headerBuffer) + private int WriteHeaderCollection(HttpRequestMessage request, HttpHeaders headers, ref ArrayBuffer headerBuffer) { if (NetEventSource.Log.IsEnabled()) Trace(""); HeaderEncodingSelector? encodingSelector = _pool.Settings._requestHeaderEncodingSelector; ref string[]? tmpHeaderValuesArray = ref t_headerValues; - foreach (HeaderEntry header in headers.GetEntries()) + + ReadOnlySpan entries = headers.GetEntries(); + int headerListSize = entries.Length * HeaderField.RfcOverhead; + + foreach (HeaderEntry header in entries) { int headerValuesCount = HttpHeaders.GetStoreValuesIntoStringArray(header.Key, header.Value, ref tmpHeaderValuesArray); Debug.Assert(headerValuesCount > 0, "No values for header??"); @@ -1402,6 +1425,10 @@ private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders heade // The Connection, Upgrade and ProxyConnection headers are also not supported in HTTP2. if (knownHeader != KnownHeaders.Host && knownHeader != KnownHeaders.Connection && knownHeader != KnownHeaders.Upgrade && knownHeader != KnownHeaders.ProxyConnection) { + // The length of the encoded name may be shorter than the actual name. + // Ensure that headerListSize is always >= of the actual size. + headerListSize += knownHeader.Name.Length; + if (knownHeader == KnownHeaders.TE) { // HTTP/2 allows only 'trailers' TE header. rfc7540 8.1.2.2 @@ -1442,6 +1469,8 @@ private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders heade WriteLiteralHeader(header.Key.Name, headerValues, valueEncoding, ref headerBuffer); } } + + return headerListSize; } private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuffer) @@ -1472,9 +1501,9 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff WriteIndexedHeader(_pool.IsSecure ? H2StaticTable.SchemeHttps : H2StaticTable.SchemeHttp, ref headerBuffer); - if (request.HasHeaders && request.Headers.Host != null) + if (request.HasHeaders && request.Headers.Host is string host) { - WriteIndexedHeader(H2StaticTable.Authority, request.Headers.Host, ref headerBuffer); + WriteIndexedHeader(H2StaticTable.Authority, host, ref headerBuffer); } else { @@ -1492,6 +1521,8 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff WriteIndexedHeader(H2StaticTable.PathSlash, pathAndQuery, ref headerBuffer); } + int headerListSize = 3 * HeaderField.RfcOverhead; // Method, Authority, Path + if (request.HasHeaders) { if (request.Headers.Protocol != null) @@ -1499,9 +1530,10 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff WriteBytes(ProtocolLiteralHeaderBytes, ref headerBuffer); Encoding? protocolEncoding = _pool.Settings._requestHeaderEncodingSelector?.Invoke(":protocol", request); WriteLiteralHeaderValue(request.Headers.Protocol, protocolEncoding, ref headerBuffer); + headerListSize += HeaderField.RfcOverhead; } - WriteHeaderCollection(request, request.Headers, ref headerBuffer); + headerListSize += WriteHeaderCollection(request, request.Headers, ref headerBuffer); } // Determine cookies to send. @@ -1511,9 +1543,9 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff if (cookiesFromContainer != string.Empty) { WriteBytes(KnownHeaders.Cookie.Http2EncodedName, ref headerBuffer); - Encoding? cookieEncoding = _pool.Settings._requestHeaderEncodingSelector?.Invoke(KnownHeaders.Cookie.Name, request); WriteLiteralHeaderValue(cookiesFromContainer, cookieEncoding, ref headerBuffer); + headerListSize += HttpKnownHeaderNames.Cookie.Length + HeaderField.RfcOverhead; } } @@ -1525,11 +1557,24 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff { WriteBytes(KnownHeaders.ContentLength.Http2EncodedName, ref headerBuffer); WriteLiteralHeaderValue("0", valueEncoding: null, ref headerBuffer); + headerListSize += HttpKnownHeaderNames.ContentLength.Length + HeaderField.RfcOverhead; } } else { - WriteHeaderCollection(request, request.Content.Headers, ref headerBuffer); + headerListSize += WriteHeaderCollection(request, request.Content.Headers, ref headerBuffer); + } + + // The headerListSize is an approximation of the total header length. + // This is acceptable as long as the value is always >= the actual length. + // We must avoid ever sending more than the server allowed. + // This approach must be revisted if we ever support the dynamic table or compression when sending requests. + headerListSize += headerBuffer.ActiveLength; + + uint maxHeaderListSize = _maxHeaderListSize; + if ((uint)headerListSize > maxHeaderListSize) + { + throw new HttpRequestException(SR.Format(SR.net_http_request_headers_exceeded_length, maxHeaderListSize)); } } @@ -1602,10 +1647,10 @@ private async ValueTask SendHeadersAsync(HttpRequestMessage request // streams are created and started in order. await PerformWriteAsync(totalSize, (thisRef: this, http2Stream, headerBytes, endStream: (request.Content == null && !request.IsExtendedConnectRequest), mustFlush), static (s, writeBuffer) => { - if (NetEventSource.Log.IsEnabled()) s.thisRef.Trace(s.http2Stream.StreamId, $"Started writing. Total header bytes={s.headerBytes.Length}"); - s.thisRef.AddStream(s.http2Stream); + if (NetEventSource.Log.IsEnabled()) s.thisRef.Trace(s.http2Stream.StreamId, $"Started writing. Total header bytes={s.headerBytes.Length}"); + Span span = writeBuffer.Span; // Copy the HEADERS frame. diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs index 688ba839abb45..ead8e5e06123b 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3Connection.cs @@ -36,8 +36,9 @@ internal sealed class Http3Connection : HttpConnectionBase // Our control stream. private QuicStream? _clientControl; - // Current SETTINGS from the server. - private int _maximumHeadersLength = int.MaxValue; // TODO: this is not yet observed by Http3Stream when buffering headers. + // Server-advertised SETTINGS_MAX_FIELD_SECTION_SIZE + // https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.4.1-2.2.1 + private uint _maxHeaderListSize = uint.MaxValue; // Defaults to infinite // Once the server's streams are received, these are set to 1. Further receipt of these streams results in a connection error. private int _haveServerControlStream; @@ -53,7 +54,7 @@ internal sealed class Http3Connection : HttpConnectionBase public HttpAuthority Authority => _authority; public HttpConnectionPool Pool => _pool; - public int MaximumRequestHeadersLength => _maximumHeadersLength; + public uint MaxHeaderListSize => _maxHeaderListSize; public byte[]? AltUsedEncodedHeaderBytes => _altUsedEncodedHeader; public Exception? AbortException => Volatile.Read(ref _abortException); private object SyncObj => _activeRequests; @@ -84,6 +85,13 @@ public Http3Connection(HttpConnectionPool pool, HttpAuthority? origin, HttpAutho _altUsedEncodedHeader = QPack.QPackEncoder.EncodeLiteralHeaderFieldWithoutNameReferenceToArray(KnownHeaders.AltUsed.Name, altUsedValue); } + uint maxHeaderListSize = _pool._lastSeenHttp3MaxHeaderListSize; + if (maxHeaderListSize > 0) + { + // Previous connections to the same host advertised a limit. + // Use this as an initial value before we receive the SETTINGS frame. + _maxHeaderListSize = maxHeaderListSize; + } if (HttpTelemetry.Log.IsEnabled()) { @@ -725,10 +733,13 @@ async ValueTask ProcessSettingsFrameAsync(long settingsPayloadLength) buffer.Discard(bytesRead); + if (NetEventSource.Log.IsEnabled()) Trace($"Applying setting {(Http3SettingType)settingId}={settingValue}"); + switch ((Http3SettingType)settingId) { case Http3SettingType.MaxHeaderListSize: - _maximumHeadersLength = (int)Math.Min(settingValue, int.MaxValue); + _maxHeaderListSize = (uint)Math.Min((ulong)settingValue, uint.MaxValue); + _pool._lastSeenHttp3MaxHeaderListSize = _maxHeaderListSize; break; case Http3SettingType.ReservedHttp2EnablePush: case Http3SettingType.ReservedHttp2MaxConcurrentStreams: diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs index 7d0ddf757bdb2..0386447b01461 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs @@ -575,9 +575,9 @@ private void BufferHeaders(HttpRequestMessage request) BufferBytes(normalizedMethod.Http3EncodedBytes); BufferIndexedHeader(H3StaticTable.SchemeHttps); - if (request.HasHeaders && request.Headers.Host != null) + if (request.HasHeaders && request.Headers.Host is string host) { - BufferLiteralHeaderWithStaticNameReference(H3StaticTable.Authority, request.Headers.Host); + BufferLiteralHeaderWithStaticNameReference(H3StaticTable.Authority, host); } else { @@ -598,6 +598,8 @@ private void BufferHeaders(HttpRequestMessage request) // The only way to reach H3 is to upgrade via an Alt-Svc header, so we can encode Alt-Used for every connection. BufferBytes(_connection.AltUsedEncodedHeaderBytes); + int headerListSize = 4 * HeaderField.RfcOverhead; // Scheme, Method, Authority, Path + if (request.HasHeaders) { // H3 does not support Transfer-Encoding: chunked. @@ -606,7 +608,7 @@ private void BufferHeaders(HttpRequestMessage request) request.Headers.TransferEncodingChunked = false; } - BufferHeaderCollection(request.Headers); + headerListSize += BufferHeaderCollection(request.Headers); } if (_connection.Pool.Settings._useCookies) @@ -616,6 +618,7 @@ private void BufferHeaders(HttpRequestMessage request) { Encoding? valueEncoding = _connection.Pool.Settings._requestHeaderEncodingSelector?.Invoke(HttpKnownHeaderNames.Cookie, request); BufferLiteralHeaderWithStaticNameReference(H3StaticTable.Cookie, cookiesFromContainer, valueEncoding); + headerListSize += HttpKnownHeaderNames.Cookie.Length + HeaderField.RfcOverhead; } } @@ -624,11 +627,12 @@ private void BufferHeaders(HttpRequestMessage request) if (normalizedMethod.MustHaveRequestBody) { BufferIndexedHeader(H3StaticTable.ContentLength0); + headerListSize += HttpKnownHeaderNames.ContentLength.Length + HeaderField.RfcOverhead; } } else { - BufferHeaderCollection(request.Content.Headers); + headerListSize += BufferHeaderCollection(request.Content.Headers); } // Determine our header envelope size. @@ -642,15 +646,30 @@ private void BufferHeaders(HttpRequestMessage request) int actualHeadersLengthEncodedSize = VariableLengthIntegerHelper.WriteInteger(_sendBuffer.ActiveSpan.Slice(1, headersLengthEncodedSize), headersLength); Debug.Assert(actualHeadersLengthEncodedSize == headersLengthEncodedSize); + // The headerListSize is an approximation of the total header length. + // This is acceptable as long as the value is always >= the actual length. + // We must avoid ever sending more than the server allowed. + // This approach must be revisted if we ever support the dynamic table or compression when sending requests. + headerListSize += headersLength; + + uint maxHeaderListSize = _connection.MaxHeaderListSize; + if ((uint)headerListSize > maxHeaderListSize) + { + throw new HttpRequestException(SR.Format(SR.net_http_request_headers_exceeded_length, maxHeaderListSize)); + } + if (HttpTelemetry.Log.IsEnabled()) HttpTelemetry.Log.RequestHeadersStop(); } // TODO: special-case Content-Type for static table values values? - private void BufferHeaderCollection(HttpHeaders headers) + private int BufferHeaderCollection(HttpHeaders headers) { HeaderEncodingSelector? encodingSelector = _connection.Pool.Settings._requestHeaderEncodingSelector; - foreach (HeaderEntry header in headers.GetEntries()) + ReadOnlySpan entries = headers.GetEntries(); + int headerListSize = entries.Length * HeaderField.RfcOverhead; + + foreach (HeaderEntry header in entries) { int headerValuesCount = HttpHeaders.GetStoreValuesIntoStringArray(header.Key, header.Value, ref _headerValues); Debug.Assert(headerValuesCount > 0, "No values for header??"); @@ -666,6 +685,10 @@ private void BufferHeaderCollection(HttpHeaders headers) // The Connection, Upgrade and ProxyConnection headers are also not supported in HTTP/3. if (knownHeader != KnownHeaders.Host && knownHeader != KnownHeaders.Connection && knownHeader != KnownHeaders.Upgrade && knownHeader != KnownHeaders.ProxyConnection) { + // The length of the encoded name may be shorter than the actual name. + // Ensure that headerListSize is always >= of the actual size. + headerListSize += knownHeader.Name.Length; + if (knownHeader == KnownHeaders.TE) { // HTTP/2 allows only 'trailers' TE header. rfc7540 8.1.2.2 @@ -706,6 +729,8 @@ private void BufferHeaderCollection(HttpHeaders headers) BufferLiteralHeaderWithoutNameReference(header.Key.Name, headerValues, HttpHeaderParser.DefaultSeparator, valueEncoding); } } + + return headerListSize; } private void BufferIndexedHeader(int index) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs index e0852075260f3..c10a3bf1ea032 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs @@ -98,6 +98,15 @@ internal sealed class HttpConnectionPool : IDisposable private SemaphoreSlim? _http3ConnectionCreateLock; internal readonly byte[]? _http3EncodedAuthorityHostHeader; + // These settings are advertised by the server via SETTINGS_MAX_HEADER_LIST_SIZE and SETTINGS_MAX_FIELD_SECTION_SIZE. + // If we had previous connections to the same host in this pool, memorize the last value seen. + // This value is used as an initial value for new connections before they have a chance to observe the SETTINGS frame. + // Doing so avoids immediately exceeding the server limit on the first request, potentially causing the connection to be torn down. + // 0 means there were no previous connections, or they hadn't advertised this limit. + // There is no need to lock when updating these values - we're only interested in saving _a_ value, not necessarily the min/max/last. + internal uint _lastSeenHttp2MaxHeaderListSize; + internal uint _lastSeenHttp3MaxHeaderListSize; + /// For non-proxy connection pools, this is the host name in bytes; for proxies, null. private readonly byte[]? _hostHeaderValueBytes; /// Options specialized and cached for this pool and its key. diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 10a704a7e8b0e..7411588c9a822 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -1229,8 +1229,150 @@ public void Expect100ContinueTimeout_SetAfterUse_Throws() } } + public abstract class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength : HttpClientHandler_MaxResponseHeadersLength_Test + { + public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task ServerAdvertisedMaxHeaderListSize_IsHonoredByClient() + { + if (UseVersion.Major == 1) + { + // HTTP/1.X doesn't have a concept of SETTINGS_MAX_HEADER_LIST_SIZE. + return; + } + + // On HTTP/3 there is no synchronization between regular requests and the acknowledgement of the SETTINGS frame. + // Retry the test with increasing delays to give the client connection a chance to observe the settings. + int retry = 0; + await RetryHelper.ExecuteAsync(async () => + { + retry++; + + const int Limit = 10_000; + + using HttpClientHandler handler = CreateHttpClientHandler(); + using HttpClient client = CreateHttpClient(handler); + + // We want to test that the client remembered the setting it received from the previous connection. + // To do this, we trick the client into using the same HttpConnectionPool for both server connections. + // We only have control over the ConnectCallback on HTTP/2. + bool fakeRequestHost = UseVersion.Major == 2; + Uri lastServerUri = null; + + GetUnderlyingSocketsHttpHandler(handler).ConnectCallback = async (context, ct) => + { + Assert.Equal("foo", context.DnsEndPoint.Host); + + Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + try + { + await socket.ConnectAsync(lastServerUri.IdnHost, lastServerUri.Port); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }; + + TaskCompletionSource waitingForLastRequest = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + if (fakeRequestHost) + { + lastServerUri = uri; + uri = new UriBuilder(uri) { Host = "foo", Port = 42 }.Uri; + } + + // Send a dummy request to ensure the SETTINGS frame has been received. + Assert.Equal("Hello world", await client.GetStringAsync(uri)); + + if (retry > 1) + { + // Give the client HTTP/3 connection a chance to observe the SETTINGS frame. + await Task.Delay(100 * retry); + } + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + request.Headers.Add("Foo", new string('a', Limit)); + + Exception ex = await Assert.ThrowsAsync(() => client.SendAsync(request)); + Assert.Contains(Limit.ToString(), ex.Message); + + request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + for (int i = 0; i < Limit / 40; i++) + { + request.Headers.Add($"Foo-{i}", ""); + } + + ex = await Assert.ThrowsAsync(() => client.SendAsync(request)); + Assert.Contains(Limit.ToString(), ex.Message); + + await waitingForLastRequest.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + // Ensure that the connection is still usable for requests that don't hit the limit. + Assert.Equal("Hello world", await client.GetStringAsync(uri)); + }, + async server => + { + var setting = new SettingsEntry { SettingId = SettingId.MaxHeaderListSize, Value = Limit }; + + await using GenericLoopbackConnection connection = UseVersion.Major == 2 + ? await ((Http2LoopbackServer)server).EstablishConnectionAsync(setting) + : await ((Http3LoopbackServer)server).EstablishConnectionAsync(setting); + + await connection.ReadRequestDataAsync(); + await connection.SendResponseAsync(content: "Hello world"); + + // On HTTP/3, the client will establish a request stream before buffering the headers. + // Swallow two streams to account for the client creating and closing them before reporting the error. + if (connection is Http3LoopbackConnection http3Connection) + { + await http3Connection.AcceptRequestStreamAsync().WaitAsync(TimeSpan.FromSeconds(10)); + await http3Connection.AcceptRequestStreamAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + + waitingForLastRequest.SetResult(); + + // HandleRequestAsync will close the connection + await connection.HandleRequestAsync(content: "Hello world"); + + if (UseVersion.Major == 3) + { + await ((Http3LoopbackConnection)connection).ShutdownAsync(); + } + }); + + if (fakeRequestHost) + { + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + lastServerUri = uri; + uri = new UriBuilder(uri) { Host = "foo", Port = 42 }.Uri; + + HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true); + request.Headers.Add("Foo", new string('a', Limit)); + + Exception ex = await Assert.ThrowsAsync(() => client.SendAsync(request)); + Assert.Contains(Limit.ToString(), ex.Message); + + // Ensure that the connection is still usable for requests that don't hit the limit. + Assert.Equal("Hello world", await client.GetStringAsync(uri)); + }, + async server => + { + await server.HandleRequestAsync(content: "Hello world"); + }); + } + }, maxAttempts: UseVersion.Major == 3 ? 5 : 1); + } + } + [ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] - public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http11 : HttpClientHandler_MaxResponseHeadersLength_Test + public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http11 : SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength { public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http11(ITestOutputHelper output) : base(output) { } @@ -1396,7 +1538,7 @@ public async Task LargeHeaders_TrickledOverTime_ProcessedEfficiently(bool traili } [ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))] - public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2 : HttpClientHandler_MaxResponseHeadersLength_Test + public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2 : SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength { public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2(ITestOutputHelper output) : base(output) { } protected override Version UseVersion => HttpVersion.Version20; @@ -1404,7 +1546,7 @@ public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2(ITest [ActiveIssue("https://github.com/dotnet/runtime/issues/74896")] [ConditionalClass(typeof(HttpClientHandlerTestBase), nameof(IsQuicSupported))] - public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http3 : HttpClientHandler_MaxResponseHeadersLength_Test + public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http3 : SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength { public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http3(ITestOutputHelper output) : base(output) { } protected override Version UseVersion => HttpVersion.Version30;