Skip to content

Commit

Permalink
Respect SETTINGS_MAX_HEADER_LIST_SIZE on HTTP/2 (#79997)
Browse files Browse the repository at this point in the history
  • Loading branch information
MihaZupan authored Jan 5, 2023
1 parent 756342b commit cb73f5d
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 9 deletions.
3 changes: 3 additions & 0 deletions src/libraries/System.Net.Http/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,9 @@
<data name="net_http_buffer_insufficient_length" xml:space="preserve">
<value>The buffer was not long enough.</value>
</data>
<data name="net_http_request_headers_exceeded_length" xml:space="preserve">
<value>The HTTP request headers length exceeded the server limit of {0} bytes.</value>
</data>
<data name="net_http_response_headers_exceeded_length" xml:space="preserve">
<value>The HTTP response headers length exceeded the set limit of {0} bytes.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ internal sealed partial class Http2Connection : HttpConnectionBase
private readonly Channel<WriteQueueEntry> _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)
Expand Down Expand Up @@ -156,6 +160,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();
Expand Down Expand Up @@ -800,6 +812,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:
Expand All @@ -825,6 +839,11 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f
// We don't actually store this value; we always send frames of the minimum size (16K).
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.
Expand Down Expand Up @@ -1332,17 +1351,19 @@ private void WriteBytes(ReadOnlySpan<byte> 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("");

if (headers.HeaderStore is null)
{
return;
return 0;
}

HeaderEncodingSelector<HttpRequestMessage>? encodingSelector = _pool.Settings._requestHeaderEncodingSelector;

int headerListSize = headers.HeaderStore.Count * HeaderField.RfcOverhead;

ref string[]? tmpHeaderValuesArray = ref t_headerValues;
foreach (KeyValuePair<HeaderDescriptor, object> header in headers.HeaderStore)
{
Expand All @@ -1360,6 +1381,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 (header.Key.KnownHeader == KnownHeaders.TE)
{
// HTTP/2 allows only 'trailers' TE header. rfc7540 8.1.2.2
Expand Down Expand Up @@ -1400,6 +1425,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)
Expand Down Expand Up @@ -1430,9 +1457,9 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff

WriteIndexedHeader(_stream is SslStream ? 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
{
Expand All @@ -1450,9 +1477,11 @@ 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)
{
WriteHeaderCollection(request, request.Headers, ref headerBuffer);
headerListSize += WriteHeaderCollection(request, request.Headers, ref headerBuffer);
}

// Determine cookies to send.
Expand All @@ -1462,9 +1491,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;
}
}

Expand All @@ -1476,11 +1505,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));
}
}

Expand Down Expand Up @@ -1553,10 +1595,10 @@ private async ValueTask<Http2Stream> SendHeadersAsync(HttpRequestMessage request
// streams are created and started in order.
await PerformWriteAsync(totalSize, (thisRef: this, http2Stream, headerBytes, endStream: (request.Content == null), 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<byte> span = writeBuffer.Span;

// Copy the HEADERS frame.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ 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.
// 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;

/// <summary>For non-proxy connection pools, this is the host name in bytes; for proxies, null.</summary>
private readonly byte[]? _hostHeaderValueBytes;
/// <summary>Options specialized and cached for this pool and its key.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,107 @@ public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLengt
public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Test(ITestOutputHelper output) : base(output) { }
}

[ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))]
public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2 : HttpClientHandlerTestBase
{
public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2(ITestOutputHelper output) : base(output) { }
protected override Version UseVersion => HttpVersion.Version20;

[Fact]
public async Task ServerAdvertisedMaxHeaderListSize_IsHonoredByClient()
{
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.
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 =>
{
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));

HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true);
request.Headers.Add("Foo", new string('a', Limit));

Exception ex = await Assert.ThrowsAsync<HttpRequestException>(() => 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<HttpRequestException>(() => 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 };

using GenericLoopbackConnection connection = await ((Http2LoopbackServer)server).EstablishConnectionAsync(setting);

await connection.ReadRequestDataAsync();
await connection.SendResponseAsync(content: "Hello world");

waitingForLastRequest.SetResult();

// HandleRequestAsync will close the connection
await connection.HandleRequestAsync(content: "Hello world");
});

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<HttpRequestException>(() => 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");
});
}
}

[SkipOnPlatform(TestPlatforms.Browser, "Socket is not supported on Browser")]
public sealed class SocketsHttpHandler_HttpClientHandler_Authentication_Test : HttpClientHandler_Authentication_Test
{
Expand Down

0 comments on commit cb73f5d

Please sign in to comment.