Skip to content

Commit cb73f5d

Browse files
authored
Respect SETTINGS_MAX_HEADER_LIST_SIZE on HTTP/2 (#79997)
1 parent 756342b commit cb73f5d

File tree

4 files changed

+163
-9
lines changed

4 files changed

+163
-9
lines changed

src/libraries/System.Net.Http/src/Resources/Strings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,9 @@
360360
<data name="net_http_buffer_insufficient_length" xml:space="preserve">
361361
<value>The buffer was not long enough.</value>
362362
</data>
363+
<data name="net_http_request_headers_exceeded_length" xml:space="preserve">
364+
<value>The HTTP request headers length exceeded the server limit of {0} bytes.</value>
365+
</data>
363366
<data name="net_http_response_headers_exceeded_length" xml:space="preserve">
364367
<value>The HTTP response headers length exceeded the set limit of {0} bytes.</value>
365368
</data>

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ internal sealed partial class Http2Connection : HttpConnectionBase
5050
private readonly Channel<WriteQueueEntry> _writeChannel;
5151
private bool _lastPendingWriterShouldFlush;
5252

53+
// Server-advertised SETTINGS_MAX_HEADER_LIST_SIZE
54+
// https://www.rfc-editor.org/rfc/rfc9113.html#section-6.5.2-2.12.1
55+
private uint _maxHeaderListSize = uint.MaxValue; // Defaults to infinite
56+
5357
// This flag indicates that the connection is shutting down and cannot accept new requests, because of one of the following conditions:
5458
// (1) We received a GOAWAY frame from the server
5559
// (2) We have exhaustead StreamIds (i.e. _nextStream == MaxStreamId)
@@ -156,6 +160,14 @@ public Http2Connection(HttpConnectionPool pool, Stream stream)
156160
_nextPingRequestTimestamp = Environment.TickCount64 + _keepAlivePingDelay;
157161
_keepAlivePingPolicy = _pool.Settings._keepAlivePingPolicy;
158162

163+
uint maxHeaderListSize = _pool._lastSeenHttp2MaxHeaderListSize;
164+
if (maxHeaderListSize > 0)
165+
{
166+
// Previous connections to the same host advertised a limit.
167+
// Use this as an initial value before we receive the SETTINGS frame.
168+
_maxHeaderListSize = maxHeaderListSize;
169+
}
170+
159171
if (HttpTelemetry.Log.IsEnabled())
160172
{
161173
HttpTelemetry.Log.Http20ConnectionEstablished();
@@ -800,6 +812,8 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f
800812
uint settingValue = BinaryPrimitives.ReadUInt32BigEndian(settings);
801813
settings = settings.Slice(4);
802814

815+
if (NetEventSource.Log.IsEnabled()) Trace($"Applying setting {(SettingId)settingId}={settingValue}");
816+
803817
switch ((SettingId)settingId)
804818
{
805819
case SettingId.MaxConcurrentStreams:
@@ -825,6 +839,11 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f
825839
// We don't actually store this value; we always send frames of the minimum size (16K).
826840
break;
827841

842+
case SettingId.MaxHeaderListSize:
843+
_maxHeaderListSize = settingValue;
844+
_pool._lastSeenHttp2MaxHeaderListSize = _maxHeaderListSize;
845+
break;
846+
828847
default:
829848
// All others are ignored because we don't care about them.
830849
// Note, per RFC, unknown settings IDs should be ignored.
@@ -1332,17 +1351,19 @@ private void WriteBytes(ReadOnlySpan<byte> bytes, ref ArrayBuffer headerBuffer)
13321351
headerBuffer.Commit(bytes.Length);
13331352
}
13341353

1335-
private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders headers, ref ArrayBuffer headerBuffer)
1354+
private int WriteHeaderCollection(HttpRequestMessage request, HttpHeaders headers, ref ArrayBuffer headerBuffer)
13361355
{
13371356
if (NetEventSource.Log.IsEnabled()) Trace("");
13381357

13391358
if (headers.HeaderStore is null)
13401359
{
1341-
return;
1360+
return 0;
13421361
}
13431362

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

1365+
int headerListSize = headers.HeaderStore.Count * HeaderField.RfcOverhead;
1366+
13461367
ref string[]? tmpHeaderValuesArray = ref t_headerValues;
13471368
foreach (KeyValuePair<HeaderDescriptor, object> header in headers.HeaderStore)
13481369
{
@@ -1360,6 +1381,10 @@ private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders heade
13601381
// The Connection, Upgrade and ProxyConnection headers are also not supported in HTTP2.
13611382
if (knownHeader != KnownHeaders.Host && knownHeader != KnownHeaders.Connection && knownHeader != KnownHeaders.Upgrade && knownHeader != KnownHeaders.ProxyConnection)
13621383
{
1384+
// The length of the encoded name may be shorter than the actual name.
1385+
// Ensure that headerListSize is always >= of the actual size.
1386+
headerListSize += knownHeader.Name.Length;
1387+
13631388
if (header.Key.KnownHeader == KnownHeaders.TE)
13641389
{
13651390
// HTTP/2 allows only 'trailers' TE header. rfc7540 8.1.2.2
@@ -1400,6 +1425,8 @@ private void WriteHeaderCollection(HttpRequestMessage request, HttpHeaders heade
14001425
WriteLiteralHeader(header.Key.Name, headerValues, valueEncoding, ref headerBuffer);
14011426
}
14021427
}
1428+
1429+
return headerListSize;
14031430
}
14041431

14051432
private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuffer)
@@ -1430,9 +1457,9 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff
14301457

14311458
WriteIndexedHeader(_stream is SslStream ? H2StaticTable.SchemeHttps : H2StaticTable.SchemeHttp, ref headerBuffer);
14321459

1433-
if (request.HasHeaders && request.Headers.Host != null)
1460+
if (request.HasHeaders && request.Headers.Host is string host)
14341461
{
1435-
WriteIndexedHeader(H2StaticTable.Authority, request.Headers.Host, ref headerBuffer);
1462+
WriteIndexedHeader(H2StaticTable.Authority, host, ref headerBuffer);
14361463
}
14371464
else
14381465
{
@@ -1450,9 +1477,11 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff
14501477
WriteIndexedHeader(H2StaticTable.PathSlash, pathAndQuery, ref headerBuffer);
14511478
}
14521479

1480+
int headerListSize = 3 * HeaderField.RfcOverhead; // Method, Authority, Path
1481+
14531482
if (request.HasHeaders)
14541483
{
1455-
WriteHeaderCollection(request, request.Headers, ref headerBuffer);
1484+
headerListSize += WriteHeaderCollection(request, request.Headers, ref headerBuffer);
14561485
}
14571486

14581487
// Determine cookies to send.
@@ -1462,9 +1491,9 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff
14621491
if (cookiesFromContainer != string.Empty)
14631492
{
14641493
WriteBytes(KnownHeaders.Cookie.Http2EncodedName, ref headerBuffer);
1465-
14661494
Encoding? cookieEncoding = _pool.Settings._requestHeaderEncodingSelector?.Invoke(KnownHeaders.Cookie.Name, request);
14671495
WriteLiteralHeaderValue(cookiesFromContainer, cookieEncoding, ref headerBuffer);
1496+
headerListSize += HttpKnownHeaderNames.Cookie.Length + HeaderField.RfcOverhead;
14681497
}
14691498
}
14701499

@@ -1476,11 +1505,24 @@ private void WriteHeaders(HttpRequestMessage request, ref ArrayBuffer headerBuff
14761505
{
14771506
WriteBytes(KnownHeaders.ContentLength.Http2EncodedName, ref headerBuffer);
14781507
WriteLiteralHeaderValue("0", valueEncoding: null, ref headerBuffer);
1508+
headerListSize += HttpKnownHeaderNames.ContentLength.Length + HeaderField.RfcOverhead;
14791509
}
14801510
}
14811511
else
14821512
{
1483-
WriteHeaderCollection(request, request.Content.Headers, ref headerBuffer);
1513+
headerListSize += WriteHeaderCollection(request, request.Content.Headers, ref headerBuffer);
1514+
}
1515+
1516+
// The headerListSize is an approximation of the total header length.
1517+
// This is acceptable as long as the value is always >= the actual length.
1518+
// We must avoid ever sending more than the server allowed.
1519+
// This approach must be revisted if we ever support the dynamic table or compression when sending requests.
1520+
headerListSize += headerBuffer.ActiveLength;
1521+
1522+
uint maxHeaderListSize = _maxHeaderListSize;
1523+
if ((uint)headerListSize > maxHeaderListSize)
1524+
{
1525+
throw new HttpRequestException(SR.Format(SR.net_http_request_headers_exceeded_length, maxHeaderListSize));
14841526
}
14851527
}
14861528

@@ -1553,10 +1595,10 @@ private async ValueTask<Http2Stream> SendHeadersAsync(HttpRequestMessage request
15531595
// streams are created and started in order.
15541596
await PerformWriteAsync(totalSize, (thisRef: this, http2Stream, headerBytes, endStream: (request.Content == null), mustFlush), static (s, writeBuffer) =>
15551597
{
1556-
if (NetEventSource.Log.IsEnabled()) s.thisRef.Trace(s.http2Stream.StreamId, $"Started writing. Total header bytes={s.headerBytes.Length}");
1557-
15581598
s.thisRef.AddStream(s.http2Stream);
15591599

1600+
if (NetEventSource.Log.IsEnabled()) s.thisRef.Trace(s.http2Stream.StreamId, $"Started writing. Total header bytes={s.headerBytes.Length}");
1601+
15601602
Span<byte> span = writeBuffer.Span;
15611603

15621604
// Copy the HEADERS frame.

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ internal sealed class HttpConnectionPool : IDisposable
9696
private SemaphoreSlim? _http3ConnectionCreateLock;
9797
internal readonly byte[]? _http3EncodedAuthorityHostHeader;
9898

99+
// These settings are advertised by the server via SETTINGS_MAX_HEADER_LIST_SIZE.
100+
// If we had previous connections to the same host in this pool, memorize the last value seen.
101+
// This value is used as an initial value for new connections before they have a chance to observe the SETTINGS frame.
102+
// Doing so avoids immediately exceeding the server limit on the first request, potentially causing the connection to be torn down.
103+
// 0 means there were no previous connections, or they hadn't advertised this limit.
104+
// There is no need to lock when updating these values - we're only interested in saving _a_ value, not necessarily the min/max/last.
105+
internal uint _lastSeenHttp2MaxHeaderListSize;
106+
99107
/// <summary>For non-proxy connection pools, this is the host name in bytes; for proxies, null.</summary>
100108
private readonly byte[]? _hostHeaderValueBytes;
101109
/// <summary>Options specialized and cached for this pool and its key.</summary>

src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,107 @@ public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLengt
12051205
public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Test(ITestOutputHelper output) : base(output) { }
12061206
}
12071207

1208+
[ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))]
1209+
public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2 : HttpClientHandlerTestBase
1210+
{
1211+
public SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLength_Http2(ITestOutputHelper output) : base(output) { }
1212+
protected override Version UseVersion => HttpVersion.Version20;
1213+
1214+
[Fact]
1215+
public async Task ServerAdvertisedMaxHeaderListSize_IsHonoredByClient()
1216+
{
1217+
const int Limit = 10_000;
1218+
1219+
using HttpClientHandler handler = CreateHttpClientHandler();
1220+
using HttpClient client = CreateHttpClient(handler);
1221+
1222+
// We want to test that the client remembered the setting it received from the previous connection.
1223+
// To do this, we trick the client into using the same HttpConnectionPool for both server connections.
1224+
Uri lastServerUri = null;
1225+
1226+
GetUnderlyingSocketsHttpHandler(handler).ConnectCallback = async (context, ct) =>
1227+
{
1228+
Assert.Equal("foo", context.DnsEndPoint.Host);
1229+
1230+
Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
1231+
try
1232+
{
1233+
await socket.ConnectAsync(lastServerUri.IdnHost, lastServerUri.Port);
1234+
return new NetworkStream(socket, ownsSocket: true);
1235+
}
1236+
catch
1237+
{
1238+
socket.Dispose();
1239+
throw;
1240+
}
1241+
};
1242+
1243+
TaskCompletionSource waitingForLastRequest = new(TaskCreationOptions.RunContinuationsAsynchronously);
1244+
1245+
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
1246+
{
1247+
lastServerUri = uri;
1248+
uri = new UriBuilder(uri) { Host = "foo", Port = 42 }.Uri;
1249+
1250+
// Send a dummy request to ensure the SETTINGS frame has been received.
1251+
Assert.Equal("Hello world", await client.GetStringAsync(uri));
1252+
1253+
HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true);
1254+
request.Headers.Add("Foo", new string('a', Limit));
1255+
1256+
Exception ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(request));
1257+
Assert.Contains(Limit.ToString(), ex.Message);
1258+
1259+
request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true);
1260+
for (int i = 0; i < Limit / 40; i++)
1261+
{
1262+
request.Headers.Add($"Foo-{i}", "");
1263+
}
1264+
1265+
ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(request));
1266+
Assert.Contains(Limit.ToString(), ex.Message);
1267+
1268+
await waitingForLastRequest.Task.WaitAsync(TimeSpan.FromSeconds(10));
1269+
1270+
// Ensure that the connection is still usable for requests that don't hit the limit.
1271+
Assert.Equal("Hello world", await client.GetStringAsync(uri));
1272+
},
1273+
async server =>
1274+
{
1275+
var setting = new SettingsEntry { SettingId = SettingId.MaxHeaderListSize, Value = Limit };
1276+
1277+
using GenericLoopbackConnection connection = await ((Http2LoopbackServer)server).EstablishConnectionAsync(setting);
1278+
1279+
await connection.ReadRequestDataAsync();
1280+
await connection.SendResponseAsync(content: "Hello world");
1281+
1282+
waitingForLastRequest.SetResult();
1283+
1284+
// HandleRequestAsync will close the connection
1285+
await connection.HandleRequestAsync(content: "Hello world");
1286+
});
1287+
1288+
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
1289+
{
1290+
lastServerUri = uri;
1291+
uri = new UriBuilder(uri) { Host = "foo", Port = 42 }.Uri;
1292+
1293+
HttpRequestMessage request = CreateRequest(HttpMethod.Get, uri, UseVersion, exactVersion: true);
1294+
request.Headers.Add("Foo", new string('a', Limit));
1295+
1296+
Exception ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(request));
1297+
Assert.Contains(Limit.ToString(), ex.Message);
1298+
1299+
// Ensure that the connection is still usable for requests that don't hit the limit.
1300+
Assert.Equal("Hello world", await client.GetStringAsync(uri));
1301+
},
1302+
async server =>
1303+
{
1304+
await server.HandleRequestAsync(content: "Hello world");
1305+
});
1306+
}
1307+
}
1308+
12081309
[SkipOnPlatform(TestPlatforms.Browser, "Socket is not supported on Browser")]
12091310
public sealed class SocketsHttpHandler_HttpClientHandler_Authentication_Test : HttpClientHandler_Authentication_Test
12101311
{

0 commit comments

Comments
 (0)