Skip to content

Commit d6605eb

Browse files
BrennanConroyjoperezr
authored andcommitted
Merged PR 48104: Http/3 Partial Frame support
Support partial frames for HTTP/3 in Kestrel. We incrementally parse frames as they come in.
1 parent f3013da commit d6605eb

19 files changed

+597
-163
lines changed

src/Servers/Kestrel/Core/src/CoreStrings.resx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,4 +734,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l
734734
<data name="NeedHttpsConfigurationToBindHttpsAddresses" xml:space="preserve">
735735
<value>Call UseKestrelHttpsConfiguration() on IWebHostBuilder to automatically enable HTTPS when an https:// address is used.</value>
736736
</data>
737-
</root>
737+
<data name="Http3ControlStreamFrameTooLarge" xml:space="preserve">
738+
<value>The client sent a {frameType} frame to a control stream that was too large.</value>
739+
</data>
740+
</root>

src/Servers/Kestrel/Core/src/Http3Limits.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ internal int HeaderTableSize
3737
/// <summary>
3838
/// Indicates the size of the maximum allowed size of a request header field sequence. This limit applies to both name and value sequences in their compressed and uncompressed representations.
3939
/// <para>
40-
/// Value must be greater than 0, defaults to 2^14 (16,384).
40+
/// Value must be greater than 0, defaults to 2^15 (32,768).
4141
/// </para>
4242
/// </summary>
4343
public int MaxRequestHeaderFieldSize

src/Servers/Kestrel/Core/src/Internal/Http3/Frames/Http3RawFrame.Data.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ internal partial class Http3RawFrame
77
{
88
public void PrepareData()
99
{
10-
Length = 0;
10+
RemainingLength = 0;
1111
Type = Http3FrameType.Data;
1212
}
1313
}

src/Servers/Kestrel/Core/src/Internal/Http3/Frames/Http3RawFrame.GoAway.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ internal partial class Http3RawFrame
77
{
88
public void PrepareGoAway()
99
{
10-
Length = 0;
10+
RemainingLength = 0;
1111
Type = Http3FrameType.GoAway;
1212
}
1313
}

src/Servers/Kestrel/Core/src/Internal/Http3/Frames/Http3RawFrame.Headers.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ internal partial class Http3RawFrame
77
{
88
public void PrepareHeaders()
99
{
10-
Length = 0;
10+
RemainingLength = 0;
1111
Type = Http3FrameType.Headers;
1212
}
1313
}

src/Servers/Kestrel/Core/src/Internal/Http3/Frames/Http3RawFrame.Settings.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ internal partial class Http3RawFrame
77
{
88
public void PrepareSettings()
99
{
10-
Length = 0;
10+
RemainingLength = 0;
1111
Type = Http3FrameType.Settings;
1212
}
1313
}

src/Servers/Kestrel/Core/src/Internal/Http3/Frames/Http3RawFrame.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ namespace System.Net.Http;
99
internal partial class Http3RawFrame
1010
#pragma warning restore CA1852 // Seal internal types
1111
{
12-
public long Length { get; set; }
12+
public long RemainingLength { get; set; }
1313

1414
public Http3FrameType Type { get; internal set; }
1515

1616
public string FormattedType => Http3Formatting.ToFormattedType(Type);
1717

1818
public override string ToString()
1919
{
20-
return $"{FormattedType} Length: {Length}";
20+
return $"{FormattedType} Length: {RemainingLength}";
2121
}
2222
}

src/Servers/Kestrel/Core/src/Internal/Http3/Http3ControlStream.cs

Lines changed: 121 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Buffers;
5+
using System.Diagnostics;
56
using System.Globalization;
67
using System.IO.Pipelines;
78
using System.Net.Http;
@@ -19,13 +20,18 @@ internal abstract class Http3ControlStream : IHttp3Stream, IThreadPoolWorkItem
1920
private const int EncoderStreamTypeId = 2;
2021
private const int DecoderStreamTypeId = 3;
2122

23+
// Arbitrarily chosen max frame length
24+
// ControlStream frames currently are very small, either a single variable length integer (max 8 bytes), two variable length integers,
25+
// or in the case of SETTINGS a small collection of two variable length integers
26+
// We'll use a generous value of 10k in case new optional frame(s) are added that might be a little larger than the current frames.
27+
private const int MaxFrameSize = 10_000;
28+
2229
private readonly Http3FrameWriter _frameWriter;
2330
private readonly Http3StreamContext _context;
2431
private readonly Http3PeerSettings _serverPeerSettings;
2532
private readonly IStreamIdFeature _streamIdFeature;
2633
private readonly IStreamClosedFeature _streamClosedFeature;
2734
private readonly IProtocolErrorCodeFeature _errorCodeFeature;
28-
private readonly Http3RawFrame _incomingFrame = new Http3RawFrame();
2935
private volatile int _isClosed;
3036
private long _headerType;
3137
private readonly object _completionLock = new();
@@ -159,9 +165,9 @@ private async ValueTask<long> TryReadStreamHeaderAsync()
159165
{
160166
if (!readableBuffer.IsEmpty)
161167
{
162-
var id = VariableLengthIntegerHelper.GetInteger(readableBuffer, out consumed, out examined);
163-
if (id != -1)
168+
if (VariableLengthIntegerHelper.TryGetInteger(readableBuffer, out consumed, out var id))
164169
{
170+
examined = consumed;
165171
return id;
166172
}
167173
}
@@ -240,13 +246,17 @@ public async Task ProcessRequestAsync<TContext>(IHttpApplication<TContext> appli
240246
}
241247
finally
242248
{
249+
await _context.StreamContext.DisposeAsync();
250+
243251
ApplyCompletionFlag(StreamCompletionFlags.Completed);
244252
_context.StreamLifetimeHandler.OnStreamCompleted(this);
245253
}
246254
}
247255

248256
private async Task HandleControlStream()
249257
{
258+
var incomingFrame = new Http3RawFrame();
259+
var isContinuedFrame = false;
250260
while (_isClosed == 0)
251261
{
252262
var result = await Input.ReadAsync();
@@ -259,12 +269,33 @@ private async Task HandleControlStream()
259269
if (!readableBuffer.IsEmpty)
260270
{
261271
// need to kick off httpprotocol process request async here.
262-
while (Http3FrameReader.TryReadFrame(ref readableBuffer, _incomingFrame, out var framePayload))
272+
while (Http3FrameReader.TryReadFrame(ref readableBuffer, incomingFrame, isContinuedFrame, out var framePayload))
263273
{
264-
Log.Http3FrameReceived(_context.ConnectionId, _streamIdFeature.StreamId, _incomingFrame);
265-
266-
consumed = examined = framePayload.End;
267-
await ProcessHttp3ControlStream(framePayload);
274+
Debug.Assert(incomingFrame.RemainingLength >= framePayload.Length);
275+
276+
// Only log when parsing the beginning of the frame
277+
if (!isContinuedFrame)
278+
{
279+
Log.Http3FrameReceived(_context.ConnectionId, _streamIdFeature.StreamId, incomingFrame);
280+
}
281+
282+
examined = framePayload.End;
283+
await ProcessHttp3ControlStream(incomingFrame, isContinuedFrame, framePayload, out consumed);
284+
285+
if (incomingFrame.RemainingLength == framePayload.Length)
286+
{
287+
Debug.Assert(framePayload.Slice(0, consumed).Length == framePayload.Length);
288+
289+
incomingFrame.RemainingLength = 0;
290+
isContinuedFrame = false;
291+
}
292+
else
293+
{
294+
incomingFrame.RemainingLength -= framePayload.Slice(0, consumed).Length;
295+
isContinuedFrame = true;
296+
297+
Debug.Assert(incomingFrame.RemainingLength > 0);
298+
}
268299
}
269300
}
270301

@@ -294,56 +325,71 @@ private async ValueTask HandleEncodingDecodingTask()
294325
}
295326
}
296327

297-
private ValueTask ProcessHttp3ControlStream(in ReadOnlySequence<byte> payload)
328+
private ValueTask ProcessHttp3ControlStream(Http3RawFrame incomingFrame, bool isContinuedFrame, in ReadOnlySequence<byte> payload, out SequencePosition consumed)
298329
{
299-
switch (_incomingFrame.Type)
330+
// default to consuming the entire payload, this is so that we don't need to set consumed from all the frame types that aren't implemented yet.
331+
// individual frame types can set consumed if they're implemented and want to be able to partially consume the payload.
332+
consumed = payload.End;
333+
switch (incomingFrame.Type)
300334
{
301335
case Http3FrameType.Data:
302336
case Http3FrameType.Headers:
303337
case Http3FrameType.PushPromise:
304-
// https://quicwg.org/base-drafts/draft-ietf-quic-http.html#section-7.2
305-
throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ErrorUnsupportedFrameOnControlStream(_incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame);
338+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-8.1-2.12.1
339+
throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ErrorUnsupportedFrameOnControlStream(incomingFrame.FormattedType), Http3ErrorCode.UnexpectedFrame);
306340
case Http3FrameType.Settings:
307-
return ProcessSettingsFrameAsync(payload);
341+
CheckMaxFrameSize(incomingFrame);
342+
return ProcessSettingsFrameAsync(isContinuedFrame, payload, out consumed);
308343
case Http3FrameType.GoAway:
309-
return ProcessGoAwayFrameAsync();
344+
return ProcessGoAwayFrameAsync(isContinuedFrame, incomingFrame, payload, out consumed);
310345
case Http3FrameType.CancelPush:
311-
return ProcessCancelPushFrameAsync();
346+
return ProcessCancelPushFrameAsync(incomingFrame, payload, out consumed);
312347
case Http3FrameType.MaxPushId:
313-
return ProcessMaxPushIdFrameAsync();
348+
return ProcessMaxPushIdFrameAsync(incomingFrame, payload, out consumed);
314349
default:
315-
return ProcessUnknownFrameAsync(_incomingFrame.Type);
350+
CheckMaxFrameSize(incomingFrame);
351+
return ProcessUnknownFrameAsync(incomingFrame.Type);
316352
}
317-
}
318353

319-
private ValueTask ProcessSettingsFrameAsync(ReadOnlySequence<byte> payload)
320-
{
321-
if (_haveReceivedSettingsFrame)
354+
static void CheckMaxFrameSize(Http3RawFrame http3RawFrame)
322355
{
323-
// https://quicwg.org/base-drafts/draft-ietf-quic-http.html#name-settings
324-
throw new Http3ConnectionErrorException(CoreStrings.Http3ErrorControlStreamMultipleSettingsFrames, Http3ErrorCode.UnexpectedFrame);
356+
// Not part of the RFC, but it's a good idea to limit the size of frames when we know they're supposed to be small.
357+
if (http3RawFrame.RemainingLength >= MaxFrameSize)
358+
{
359+
throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ControlStreamFrameTooLarge(http3RawFrame.FormattedType), Http3ErrorCode.FrameError);
360+
}
325361
}
362+
}
326363

327-
_haveReceivedSettingsFrame = true;
328-
_streamClosedFeature.OnClosed(static state =>
364+
private ValueTask ProcessSettingsFrameAsync(bool isContinuedFrame, ReadOnlySequence<byte> payload, out SequencePosition consumed)
365+
{
366+
if (!isContinuedFrame)
329367
{
330-
var stream = (Http3ControlStream)state!;
331-
stream.OnStreamClosed();
332-
}, this);
368+
if (_haveReceivedSettingsFrame)
369+
{
370+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.4
371+
throw new Http3ConnectionErrorException(CoreStrings.Http3ErrorControlStreamMultipleSettingsFrames, Http3ErrorCode.UnexpectedFrame);
372+
}
373+
374+
_haveReceivedSettingsFrame = true;
375+
_streamClosedFeature.OnClosed(static state =>
376+
{
377+
var stream = (Http3ControlStream)state!;
378+
stream.OnStreamClosed();
379+
}, this);
380+
}
333381

334382
while (true)
335383
{
336-
var id = VariableLengthIntegerHelper.GetInteger(payload, out var consumed, out _);
337-
if (id == -1)
384+
if (!VariableLengthIntegerHelper.TryGetInteger(payload, out consumed, out var id))
338385
{
339386
break;
340387
}
341388

342-
payload = payload.Slice(consumed);
343-
344-
var value = VariableLengthIntegerHelper.GetInteger(payload, out consumed, out _);
345-
if (value == -1)
389+
if (!VariableLengthIntegerHelper.TryGetInteger(payload.Slice(consumed), out consumed, out var value))
346390
{
391+
// Reset consumed to very start even though we successfully read 1 varint. It's because we want to keep the id for when we have the value as well.
392+
consumed = payload.Start;
347393
break;
348394
}
349395

@@ -382,37 +428,48 @@ private void ProcessSetting(long id, long value)
382428
}
383429
}
384430

385-
private ValueTask ProcessGoAwayFrameAsync()
431+
private ValueTask ProcessGoAwayFrameAsync(bool isContinuedFrame, Http3RawFrame incomingFrame, ReadOnlySequence<byte> payload, out SequencePosition consumed)
386432
{
387-
EnsureSettingsFrame(Http3FrameType.GoAway);
433+
// https://www.rfc-editor.org/rfc/rfc9114.html#name-goaway
434+
435+
// We've already triggered RequestClose since isContinuedFrame is only true
436+
// after we've already parsed the frame type and called the processing function at least once.
437+
if (!isContinuedFrame)
438+
{
439+
EnsureSettingsFrame(Http3FrameType.GoAway);
388440

389-
// StopProcessingNextRequest must be called before RequestClose to ensure it's considered client initiated.
390-
_context.Connection.StopProcessingNextRequest(serverInitiated: false);
391-
_context.ConnectionContext.Features.Get<IConnectionLifetimeNotificationFeature>()?.RequestClose();
441+
// StopProcessingNextRequest must be called before RequestClose to ensure it's considered client initiated.
442+
_context.Connection.StopProcessingNextRequest(serverInitiated: false);
443+
_context.ConnectionContext.Features.Get<IConnectionLifetimeNotificationFeature>()?.RequestClose();
444+
}
392445

393-
// https://quicwg.org/base-drafts/draft-ietf-quic-http.html#name-goaway
394-
// PUSH is not implemented so nothing to do.
446+
// PUSH is not implemented but we still want to parse the frame to do error checking
447+
ParseVarIntWithFrameLengthValidation(incomingFrame, payload, out consumed);
395448

396449
// TODO: Double check the connection remains open.
397450
return default;
398451
}
399452

400-
private ValueTask ProcessCancelPushFrameAsync()
453+
private ValueTask ProcessCancelPushFrameAsync(Http3RawFrame incomingFrame, ReadOnlySequence<byte> payload, out SequencePosition consumed)
401454
{
455+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.3
456+
402457
EnsureSettingsFrame(Http3FrameType.CancelPush);
403458

404-
// https://quicwg.org/base-drafts/draft-ietf-quic-http.html#name-cancel_push
405-
// PUSH is not implemented so nothing to do.
459+
// PUSH is not implemented but we still want to parse the frame to do error checking
460+
ParseVarIntWithFrameLengthValidation(incomingFrame, payload, out consumed);
406461

407462
return default;
408463
}
409464

410-
private ValueTask ProcessMaxPushIdFrameAsync()
465+
private ValueTask ProcessMaxPushIdFrameAsync(Http3RawFrame incomingFrame, ReadOnlySequence<byte> payload, out SequencePosition consumed)
411466
{
467+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.7
468+
412469
EnsureSettingsFrame(Http3FrameType.MaxPushId);
413470

414-
// https://quicwg.org/base-drafts/draft-ietf-quic-http.html#name-cancel_push
415-
// PUSH is not implemented so nothing to do.
471+
// PUSH is not implemented but we still want to parse the frame to do error checking
472+
ParseVarIntWithFrameLengthValidation(incomingFrame, payload, out consumed);
416473

417474
return default;
418475
}
@@ -426,6 +483,23 @@ private ValueTask ProcessUnknownFrameAsync(Http3FrameType frameType)
426483
return default;
427484
}
428485

486+
// Used for frame types that aren't (fully) implemented yet and contain a single var int as part of their framing. (CancelPush, MaxPushId, GoAway)
487+
// We want to throw an error if the length field of the frame is larger than the spec defined format of the frame.
488+
private static void ParseVarIntWithFrameLengthValidation(Http3RawFrame incomingFrame, ReadOnlySequence<byte> payload, out SequencePosition consumed)
489+
{
490+
if (!VariableLengthIntegerHelper.TryGetInteger(payload, out consumed, out _))
491+
{
492+
return;
493+
}
494+
495+
if (incomingFrame.RemainingLength > payload.Slice(0, consumed).Length)
496+
{
497+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-10.8
498+
// An implementation MUST ensure that the length of a frame exactly matches the length of the fields it contains.
499+
throw new Http3ConnectionErrorException(CoreStrings.FormatHttp3ControlStreamFrameTooLarge(Http3Formatting.ToFormattedType(incomingFrame.Type)), Http3ErrorCode.FrameError);
500+
}
501+
}
502+
429503
private void EnsureSettingsFrame(Http3FrameType frameType)
430504
{
431505
if (!_haveReceivedSettingsFrame)

0 commit comments

Comments
 (0)