Skip to content
This repository has been archived by the owner on Dec 18, 2018. It is now read-only.

Add a request body size limit #1877

Merged
merged 7 commits into from
Jun 7, 2017
Merged

Conversation

halter73
Copy link
Member

@halter73 halter73 commented Jun 1, 2017

  • Add IHttpMaxRequestBodySizeFeature
  • Improve TestConnection.Receive timeout errors

@@ -20,6 +20,9 @@ public class KestrelServerLimits
// Matches the default large_client_header_buffers in nginx.
private int _maxRequestHeadersTotalSize = 32 * 1024;

// Disabled by default.
private long? _maxRequestBodySize = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good default 😉

/// <summary>
///
/// </summary>
public interface IHttpMaxRequestBodySizeFeature
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to move to HttpAbstractions, it's not Kestrel specific.

{
if (_contentLength > _context.MaxRequestBodySize)
{
_context.RejectRequest(RequestRejectionReason.RequestBodyTooLarge);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is really misleading, it just throws an exception, it doesn't change any state.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is typical kestrel code though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been misled by this too. At one point, someone compared this to explicit throws (e.g. throw MethodThatCreatesException()) and concluded that it doesn't inline as well, enough that it hurts perf. I think that was @pakrym or @benaadams

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was tempted to change the name of RejectRequest to ThrowBadHttpRequestException as part of this change. I could still do that if we're in agreement that it makes this and all the other calling code at least slightly more readable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming to ThrowXxx probably makes sense to indicate control is not returned to caller.

if (!_context.HasStartedConsumingRequestBody)
{
_context.HasStartedConsumingRequestBody = true;
OnReadStart();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens on the second call to ReadAsync if the first one throws here and is caught by the app? You need to reverse these lines.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Start was cleaner tbh. I understand why it was removed but it meant we avoid this lazy initialize once logic.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. I definitely understand avoiding lazy initialization is cleaner. It's not even too inefficient to greedily start the pump now that the "max" size of the body pipe is 1 byte.

The problem is that the lazy init logic was required for the request feature to work nicely with Content-Length bodies, and if you are going to do some check only on the first read anyway, it's no longer cleaner to greedily start the pump.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't look like this got fixed.

{
if (ch1 == ';')
{
consumed = reader.Cursor;
examined = reader.Cursor;

AddAndCheckConsumedBytes(reader.ConsumedBytes);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Counting the framing is going to cause strange inconsistencies between Chunked, Content-Length, and HttpSysServer. @shirhatti I assume IIS does not count the chunked framing because it doesn't even see it, Http.Sys removes it, right?

do
{
ReadCursor extensionCursor;
if (ReadCursorOperations.Seek(buffer.Start, buffer.End, out extensionCursor, ByteCR) == -1)
{
// End marker not found yet
CheckExaminedBytes(buffer.Length);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the wrong limit to apply to extensions. Let's review offline.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's super trivial to change this to just count the "data" bytes if that's what we decide. I decided to do the more difficult thing first.

I'm not too concerned about breaking any apps by counting extension bytes towards the request body size limit since chunk extensions are so rare. Counting the framing bytes might make Kestrel behave differently that HttpSysServer, but the framing can easily take up to ~92% of the uploaded bytes without any chunk extensions or trailing headers. We can discuss offline how much we care about that.

input.Add("80000000\r\n");

var buffer = new byte[1024];
var ex = await Assert.ThrowsAsync<OverflowException>(async () =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be wrapped in an IOException

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a test I added for existing behavior, so technically that's a breaking change but not really in practice.

"",
"");
await connection.ReceiveForcedEnd(
"HTTP/1.1 413 Payload Too Large",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BadHttpRequestException is catchable by the app, correct? You only get this 413 if they do not catch it and it bubbles back out from the app pipeline?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly correct. All future reads would also fail, so even if the app swallowed all exceptions and didn't write to the response body, the request draining code would see the exception which would still trigger a 413. If the app writes to the response body before the request is drained (or it's a non-keepalive request that doesn't get drained), then there might not be a 413 response.

@benaadams
Copy link
Contributor

Have déjà vu 😄

Good to standardize it as a common cross server feature though

MaximumSizeLow = ServiceContext.ServerOptions.Limits.MaxRequestBufferSize ?? 0
WriterScheduler = InlineScheduler.Default,
MaximumSizeHigh = 1,
MaximumSizeLow = 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, can you explain the change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prevents double buffering. Since the request body is only exposed directly as a Stream, it's always greedily consumed (i.e. it's impossible to inspect data from the request body via a Stream without consuming the data from the pipe). This means there's no functional reason to buffer here.

There could theoretically be a perf impact which is why I posted the CopyToAsync benchmark results. This is still pretty efficient since backpressure doesn't get applied to the client until the connection's Input pipe buffers up to MaxRequestBufferSize.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, this should really be set to MaxRequestBufferSize and this limit should be decided by the transport. Basically our limits for the server shouldn't apply here. The problem is that the request is split for us between start line and headers and the body size...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidfowl Are you saying that ideally the request body Pipe would buffer instead of the connection "Input" Pipe?

{
_context.HasStartedConsumingRequestBody = true;
OnReadStart();
var ignore = PumpAsync();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observe exceptions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PumpAsync is wrapped in a giant try/catch which will Complete the body pipe writer with the error, so the app can observe the exception. I'll add a comment though.

<value>The maximum request body size cannot be modified after the app has already started reading from the request body.</value>
</data>
<data name="MaxRequestBodySizeCannotBeModifiedForUpgradedRequests" xml:space="preserve">
<value>The maximum request body size cannot be modified after the request has be upgraded.</value>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...."has been upgraded".

throw new InvalidOperationException(CoreStrings.MaxRequestBodySizeCannotBeModifiedForUpgradedRequests);
}

MaxRequestBodySize = value;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First validate the value is >= 0

@@ -499,13 +528,14 @@ private void ParseChunkedPrefix(ReadableBuffer buffer, out ReadCursor consumed,
var chunkSize = CalculateChunkSize(ch1, 0);
ch1 = ch2;

do
while (reader.ConsumedBytes < 10)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you give this magic number a name by setting it as a const?

get => _maxRequestBodySize;
set
{
if (value <= 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quirky, but technically 0 is a valid limit, right?

namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http
{
/// <summary>
///
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: add a description

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got lazy since I knew this would be moved to HttpAbstractions 😄

// TODO: Explain how to override
/// <summary>
/// Gets or sets the maximum allowed size of the current request body in bytes.
/// When set to null, the maximunm request body size is unlimited.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: maximum

{
if (_contentLength > _context.MaxRequestBodySize)
{
_context.RejectRequest(RequestRejectionReason.RequestBodyTooLarge);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been misled by this too. At one point, someone compared this to explicit throws (e.g. throw MethodThatCreatesException()) and concluded that it doesn't inline as well, enough that it hurts perf. I think that was @pakrym or @benaadams

/// <remarks>
/// Defaults to null (unlimited).
/// </remarks>
public long? MaxRequestBodySize
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests for default value, valid values and invalid values in KestrelServerLimitsTests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@@ -202,6 +203,24 @@ string IHttpRequestIdentifierFeature.TraceIdentifier
set => TraceIdentifier = value;
}

long? IHttpMaxRequestBodySizeFeature.MaxRequestBodySize
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests in FrameTests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@halter73 halter73 force-pushed the halter73/max-read branch from a094446 to 16757b0 Compare June 5, 2017 23:00
@halter73
Copy link
Member Author

halter73 commented Jun 5, 2017

⬆️ 📅

@@ -20,6 +21,10 @@ public class KestrelServerLimits
// Matches the default large_client_header_buffers in nginx.
private int _maxRequestHeadersTotalSize = 32 * 1024;

// Matches the default maxAllowedContentLength in IIS (~28.6 MB)
// https://www.iis.net/configreference/system.webserver/security/requestfiltering/requestlimits#005
private long? _maxRequestBodySize = 30000000;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided on 16mb in the meeting

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember deciding the highest default of IIS/nginx which would be this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, go read the meeting notes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see it in the meeting notes, but there's no reasoning. The decision was to use the larger default which is ~28.6 MB. I think that was written before we looked up what the largest default was. I can change it in the notes if you think that helps.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, clear it with Damian and Barry, don't just change it.

@halter73
Copy link
Member Author

halter73 commented Jun 6, 2017

> dotnet run -- -n CopyToAsync  -o Benchmarks@halter73/react-to-logging -o KestrelHttpServer@rel/2.0.0-preview2
[05:40:07.608] Starting scenario CopyToAsync on benchmark server...
[05:40:30.061] Warmup
[05:40:30.102] Starting scenario CopyToAsync on benchmark client...
[05:40:47.397] Scenario CopyToAsync completed on benchmark client
[05:40:47.397] RPS: 10562.37
[05:40:47.398] Stopping scenario CopyToAsync on benchmark client...
[05:40:47.407] Benchmark
[05:40:47.408] Starting scenario CopyToAsync on benchmark client...
[05:41:03.579] Scenario CopyToAsync completed on benchmark client
[05:41:03.579] RPS: 10638.63
[05:41:03.580] Stopping scenario CopyToAsync on benchmark client...
[05:41:03.583] Stopping scenario CopyToAsync on benchmark server...

> dotnet run -- -n CopyToAsync -o Benchmarks@halter73/react-to-logging -o KestrelHttpServer@halter73/max-read
[05:56:08.048] Starting scenario CopyToAsync on benchmark server...
[05:56:31.534] Warmup
[05:56:31.562] Starting scenario CopyToAsync on benchmark client...
[05:56:47.802] Scenario CopyToAsync completed on benchmark client
[05:56:47.802] RPS: 10438.51
[05:56:47.803] Stopping scenario CopyToAsync on benchmark client...
[05:56:47.806] Benchmark
[05:56:47.806] Starting scenario CopyToAsync on benchmark client...
[05:57:04.024] Scenario CopyToAsync completed on benchmark client
[05:57:04.024] RPS: 10563.52
[05:57:04.025] Stopping scenario CopyToAsync on benchmark client...
[05:57:04.030] Stopping scenario CopyToAsync on benchmark server...

get => MaxRequestBodySize;
set
{
if (HasStartedConsumingRequestBody)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does the application check this to make sure it's safe to do to avoid throwing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, the app has to keep track of whether or not it's read from the request body itself. I know this might break down a little if you are writing some reusable component where you want to change the upload limit, but only if nothing has been uploaded yet. That seems pretty niche though, and nothing uses this feature today ofc.

Do you want to add HasStartedConsumingRequestBody to some request feature? Do you want to do the same for WasUpgraded?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the problem is you can add middleware anywhere in the pipeline that reads the body. There needs to be a way to not throw here.

Do you want to add HasStartedConsumingRequestBody to some request feature? Do you want to do the same for WasUpgraded?

Yes, or something more generally named like our other bools of this nature (HasResponseStarted etc).

Copy link
Member

@Tratcher Tratcher Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could add it to the new IHttpMaxRequestBodySizeFeature feature: LimitCanBeChanged or IsMutable

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of those names are terrible but I agree with the sentiment.

{
throw new InvalidOperationException(CoreStrings.MaxRequestBodySizeCannotBeModifiedAfterRead);
}
if (_wasUpgraded)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

{
OnReadStart();
_context.HasStartedConsumingRequestBody = true;
var ignore = PumpAsync();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: _ = PumpAsync()

@halter73 halter73 force-pushed the halter73/max-read branch from 863ef2f to 3dbfb00 Compare June 6, 2017 23:07
/// Gets or sets the maximum allowed size of any request body in bytes.
/// When set to null, the maximum request body size is unlimited.
/// This limit has no affect on upgraded connections which are always unlimited.
/// This can be overridden by <see cref="IHttpMaxRequestBodySizeFeature"/>.
Copy link
Contributor

@cesarblum cesarblum Jun 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would change this to "This can be overridden per-request via <see cref="IHttpMaxRequestBodySizeFeature"/>."

@@ -134,6 +139,28 @@ public int MaxRequestHeaderCount
}

/// <summary>
/// Gets or sets the maximum allowed size of any request body in bytes.
/// When set to null, the maximum request body size is unlimited.
/// This limit has no affect on upgraded connections which are always unlimited.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/affect/effect

@halter73 halter73 merged commit f96c48c into rel/2.0.0-preview2 Jun 7, 2017
@natemcmaster natemcmaster deleted the halter73/max-read branch June 12, 2017 21:49
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants