-
Notifications
You must be signed in to change notification settings - Fork 10k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow HTTP2 encoder to split headers across frames #55322
Conversation
cc. @amcasey who has been in the discussions of the corresponding issue. |
Thanks for this! Thorough, as always. Some notes:
Some test questions (you may already have code/coverage for this - I haven't checked):
|
@amcasey let me reply inline:
You are wondering about headers that are roundtripped to the client with large size (previously would fail, now it could DDoS)? I need to check Kestrel's H2 header size limits (you also mention), but there is nothing in the Http2FrameWriter in this regard.
It can span into zero or more CONTINUATION frames.
There is no such place, but it could be very well built along the Kestrel's limit or an AppContext switch. Please let me know if building such a would be preference. But note, that previously "possible" use-cases still work the same as before, so the switch would only control if large headers are allowed or not -> hence a limit might be suitable option.
I did not come across compression/no-compression on this path. HPack encodes the header values into this buffer.
The header is written to a buffer, which is split into CONTINUATION frames, so it does not matter if the name or the value is being oversized.
MaxRequestHeaderFieldSize ? -> I need to test the behavior.
It works on anything that HPack writes to my understanding. I will double confirm.
-> If the long one does not fit in the same frame, yes, the initial header will be sent in a tiny frame. This is even true for the "current" behavior. |
src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs
Outdated
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs
Outdated
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs
Outdated
Show resolved
Hide resolved
Background: I've looked at HTTP2 headers a lot. I wrote some of the dynamic support and rewrote the writer and parser at one point. I haven't looked through the code in detail yet. Some initial thoughts:
|
@ladeak Thanks! Your responses make sense.
|
I don't think we need a switch. If this lands in a mid .NET 9 preview, then it should go through a lot of use and testing before GA. For example, the work David did to rewrite response writing in .NET 7(?) was much more complex and we didn't have a fallback. |
Good enough for me. I hadn't considered how well exercised this code is by TechEmpower, etc. |
@ladeak Did you receive the initial feedback you needed? Is this ready for a full review or are you still working on it. There's no rush - I just wondered whether the next steps were our responsibility. Thanks! |
@amcasey Going to come back tomorrow with some findings. |
Discussion about the header size limits: as I understood there is a general desire to have a limit. However, response headers mostly depend on the app, and the way app handles headers. I have not found limits for HTTP/1.1. An empty/default Kestrel ASP.NET Core webapp also allows as large headers as desired with h1. On the consumer-side I ran into limits though (H1): .NET When I run the app with IISExpress, it seems to only returns the remainder of 64k. (header mod 65536). @amcasey , the following questions would need further clarification:
|
Since the spec doesn't give a limit, I think we need to give users a way to override whatever we decide. I'm not sure why it would be specific to http/2 though - presumably the same concern applies to http/1.1 or http/3. My first thought for a default would be double If we were to decide this shouldn't get a public API, I'd want to go even higher - maybe 10 MB. |
@amcasey I think I have not thought about http/1.1 about a limit, given it does not have currently, and setting 64KB would be breaking, wouldn't it? (I am not sure how difficult it could be to implement this for http/1.1, http/3 looks similar to h2 in code structure. But it makes sense from the point of view you describe that it could be a setting that applies to all versions of http. A question if it is public: should it apply to a single header or to the total headers. Consumers HttpClient and Edge had a total while curl per header limit. |
Because we're guarding against resource utilization rather than malformed responses, I think the limit should apply to the total size of all headers, rather than to the size of any individual header. Similarly, if we're reducing the whole detection mechanism to a single number, I would expect trailers to be included. I'm open to feedback on both.
Yes, it would. I think we generally accept breaks in service of DoS prevention, but I agree that this is a strong argument for choosing a default that is larger than we expect anyone to use in practice. If we felt really strongly about this, I could live with adding limits to both http/2 and http/3 and not to |
@amcasey , I added a commit that has a new limit on One thing I found on the way: I would expect similar implementation would be needed on H/1.1 and H/3 (because the public setting is on Kestrel level), hence not sure if a public limit is worth all the complexity to be added. Maybe a setting called |
@amcasey I have updated the performance measurements after the latest commit. |
Thanks! It's hard to measure perf without a baseline, but it might be interesting to compare a 20 KB header with the same header split in two (i.e. two 10 KB headers). |
@amcasey I have extended the corresponding benchmark. BEFORE (LargeHeaderSize is in KB):
BEFORE with 2*10KB header
AFTER (LargeHeaderSize is in KB):
|
Looks like it's only about 10% slower than writing multiple smaller headers. Very cool! |
(Obviously, the CI failure wasn't you.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. While playing around with the code I added some tests. I pushed those to your branch.
In tests you've added, is there a reason why you're merging header/continuation frames into one buffer instead of reading payloads from frames individually? Reading payloads as they arrive is little more real world and lets you test exactly headers what each frame contains.
Some minor things:
} | ||
else | ||
{ | ||
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_headersEncodingLargeBufferSize
is updated to be bigger when required, but I don't see anywhere that resets it back it a smaller size.
For example, request 1 has a response header that is 10megs in size, so this field and the buffer eventually grows to that size. Then request 2 has a response header that is 100kb in size (larger than max frame size) but the writer rents a 10meg buffer because that is _headersEncodingLargeBufferSize
size from last time.
Is this behavior intentional? Is the size preserved to avoid retrying because a connection keeps sending large response headers? Should add a comment if that is the case.
If the behavior isn't intentional, then I believe the field is only used inside FinishWritingHeadersUnsynchronized
. It could be changed to a local variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This behavior was intentional, exactly as you said: it might be ie. a cookie that is sent on all streams and that is always large. If you think that is not a desired behavior, I am happy to make it local. In the meantime, I will leave a comment explaining this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@JamesNK There's some discussion here: #55322 (comment)
src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs
Outdated
Show resolved
Hide resolved
{ | ||
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); | ||
} | ||
ArrayPool<byte>.Shared.Return(largeHeaderBuffer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should returning the buffer be in a finally to ensure its returned when an error occurs? There are definitely ways to throw when encoding headers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding was that in these cases the buffer would be garbage collected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's my default inclination as well, but I understood we (dotnet? aspnetcore?) had guidelines saying rented objects should not be returned in the event of an exception.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me know, I am happy to change already prepared on my machine.
I used to see try-finally all over for ArrayPool, but lately I see less and less.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, there's definitely strong precedent for only recycling on success, especially when async operations are possible; as a side-effect this also avoids the need for some try
. There might be a caveat here if we expect failure in a significant % (exceptions as flow control), but I don't think that applies here
} | ||
if (largeHeaderBuffer != null) | ||
{ | ||
ArrayPool<byte>.Shared.Return(largeHeaderBuffer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be in a finally?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding was that in these cases the buffer would be garbage collected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not returning doesn't create a memory leak, but it could cause future rent calls to allocate because buffers haven't been returned.
src/Servers/Kestrel/Core/src/Internal/Http2/Http2FrameWriter.cs
Outdated
Show resolved
Hide resolved
Co-authored-by: James Newton-King <[email protected]>
… on size increase
As usual, the CI failures are unrelated. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍 - great conversation on this one
Since James and Marc are happy, I'll go over this one more time tomorrow and then we should be good to go. Unless of course there are things you're still exploring, @ladeak? |
I have explored everything I had on mind @amcasey |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that we're at the end, I'm being a bit nitpicky, but it generally LGTM.
@@ -566,32 +572,107 @@ private ValueTask<FlushResult> WriteDataAndTrailersAsync(Http2Stream stream, in | |||
} | |||
} | |||
|
|||
private void FinishWritingHeadersUnsynchronized(int streamId, int payloadLength, bool done) | |||
private void SplitHeaderFramesToOutput(int streamId, ReadOnlySpan<byte> dataToFrame, bool endOfHeaders, bool isFramePrepared) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally, I might call this something like "SplitHeaderAcrossFrames" or "WriteHeaderAsMultipleFrames". Basically, I think the name should convey that a single header will be written in multiple parts (assuming I've understood the method correctly).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there are cases where this method is called with header data that doesn't need to be split.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, such can case exists with the regular "MoreHeaders" result. I don't have a better name in mind, so I would go with the suggested SplitHeaderAcrossFrames
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO: Method name WriteHeaderDataAcrossFrames
+ comment:
Header data is written across 1-to-n frames, based on the header data size and max frame size.
{ | ||
// The size is backed in a local field and not reset for further streams on the connections. | ||
// When a large header is repeatedly returned on a connection, the buffer size won't need to be repeatedly increased. | ||
_headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will throw when the header is at least maxint / 2
bytes? What does it do when it throws? I remember there was an earlier fix to make sure an error didn't result in an infinite loop of empty writes. I'm pretty sure this won't cause that, but I thought I should confirm.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the header may be actually already larger to int.MaxValue / 2
. For example a client updates the _maxFrameSize
to a custom value and we end up here with (2/3) * int.MaxValue
Of course the next increase would throw, but to be clear, it does not limit the max response header size.
When it throws there, this is what it would throw:
info: Microsoft.AspNetCore.Server.Kestrel.Http2[38]
Connection id "0HN45U6KT28C4": HPACK encoding error while encoding headers for stream ID 1.
System.OverflowException: Arithmetic operation resulted in an overflow.
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2FrameWriter.FinishWritingHeadersUnsynchronized(Int32 streamId, Int32 payloadLength, HeaderWriteResult writeResult) in D:\repos\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http2\Http2FrameWriter.cs:line 660
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.Http2FrameWriter.WriteResponseHeadersUnsynchronized(Int32 streamId, Int32 statusCode, Http2HeadersFrameFlags headerFrameFlags, HttpResponseHeaders headers) in D:\repos\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http2\Http2FrameWriter.cs:line 523
which then ends up handled in WriteResponseHeadersUnsynchronized
with a ConnectionAbortedException
But I could only get here by forcing a large initial value to _headersEncodingLargeBufferSize
because in practise my machine throws an OOM way earlier, already when I try to allocate the string value of such large header.
Co-authored-by: Andrew Casey <[email protected]>
- Rename splitting method to SplitHeaderAcrossFrames
Co-authored-by: James Newton-King <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Woohoo!
Thanks again for all your hard work, @ladeak! |
Allow the HTTP2 encoder to split headers across frames
Enable Kestrel's HTTP2 to split large HTTP headers into HEADER and CONTINUATION frames.
Description
Kestrel's HTTP2 implementation limits the max header size to the size of the frame size. If a header's size is larger than the frame size, it throws an exception:
throw new HPackEncodingException(SR.net_http_hpack_encode_failure);
. RFC 7540 allows the headers to be split into a HEADER frame and CONTINUATION frames.Before the change Kestrel only used CONTINUATION frames when headers fitted fully within a frame.
This PR changes the above behavior by allowing to split even a single HTTP header into multiple frames. It uses an
ArrayPool<byte>
, to back the buffer used by the HPack encoder.When the HPack encoder reports that a single header does not fit the available buffer, the size of the buffer is increased. Note, that the .NET runtime implementation on HttpClient writes all headers to a single buffer before pushing it onto the output, contrary to this implementation that keeps the semantics of Kestrel. It only increases the buffer when a single header fails to be written to the output, otherwise the old behavior is kept. My intention was to keep this behavior so that memory-wise it does not use more memory than the single largest header or the max frame size.
With this PR
HPackHeaderWriter
uses an enum to tellHttp2FrameWriter
to increase the buffer or not. When the buffer is too small, its size is doubled.This behavior is also implemented for trailers. Note that in case of headers, the HEADER frame is never empty because of the response status, while this is not true for trailers. Hence there is a subtle difference when getting the buffer for the initial frame of a header vs. a trailer.
I updated existing tests asserting the previous behavior and added new tests to validate the proposed changes.
Performance
Performance-wise the change is not expected to increase throughput (given it must do more to enable this use-case) but the goal is to have the 'slow-path' is only in the case when a single header is too large. I used the existing
Http2FrameWriterBenchmark
to compare performance before and after:Before changes (updated 2024. 05. 31. main):
After changes 2024. 05. 31. Adjusting the logic of _headersEncodingLargeBufferSize to avoid 0 val…
Fixes #4722