aspnetcore
aspnetcore copied to clipboard
HTTP/2 WebSockets should not be limited by MaxRequestBodySize or MinRequestBodyDataRate
Describe the bug
HTTP/2 WebSockets (or any extended CONNECT) should not be limited by Kestrel's MaxRequestBodySize or MinRequestBodyDataRate, but currently HTTP/2 WebSockets is limited by both.
Furthermore, it should not be possible to use Response.Body.WriteAsync() to accidently write responses to extended CONNECT requests. Unlike with an HTTP/1.1 upgrade where a normal 200 response would not be treated as a WebSocket by the client, now it will be! The framing would likely be wrong, but it's a bad experience.
We should throw from Response.Body.WriteAsync() for extended CONNECT requests if the status is 200. This will at least cause Kestrel to produce a 500 response in most cases which is preferrable. I think Response.Body.ReadAsync() should throw too, but at least that currently no-ops.
I do not thing we should be reusing HttpProtocol.IsUpgraded for both HTTP/1.1 upgrades and extended CONNECTs. They are different enough we should be able to distinguished at the HttpProtocol layer if that's where the implementation of IHttpExtendedConnectFeature is going to stay
Expected Behavior
I wrote three test cases that can go in Http2WebSocketsTests that demonstrate these issues.
[Fact]
public async Task ExtendedCONNET_AcceptAsyncStream_IsNotLimitedByMinRequestBodyDataRate()
{
var limits = _serviceContext.ServerOptions.Limits;
// Use non-default value to ensure the min request and response rates aren't mixed up.
limits.MinRequestBodyDataRate = new MinDataRate(480, TimeSpan.FromSeconds(2.5));
await InitializeConnectionAsync(async context =>
{
var connectFeature = context.Features.Get<IHttpExtendedConnectFeature>();
var stream = await connectFeature.AcceptAsync();
Assert.Equal(0, await stream.ReadAsync(new byte[1])); // FAILS! ConnectionAbortedException: 'Reading the request body timed out due to data arriving too slowly. See MinRequestBodyDataRate.'
await stream.WriteAsync(new byte[] { 0x01 });
});
var headers = new[]
{
new KeyValuePair<string, string>(HeaderNames.Method, "CONNECT"),
new KeyValuePair<string, string>(HeaderNames.Protocol, "websocket"),
new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
new KeyValuePair<string, string>(HeaderNames.Path, "/chat"),
new KeyValuePair<string, string>(HeaderNames.Authority, "server.example.com"),
new KeyValuePair<string, string>(HeaderNames.WebSocketSubProtocols, "chat, superchat"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketExtensions, "permessage-deflate"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketVersion, "13"),
new KeyValuePair<string, string>(HeaderNames.Origin, "http://www.example.com"),
};
await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, headers);
// Don't send any more data and advance just to and then past the grace period.
AdvanceClock(limits.MinRequestBodyDataRate.GracePeriod + TimeSpan.FromTicks(1));
_mockTimeoutHandler.Verify(h => h.OnTimeout(It.IsAny<TimeoutReason>()), Times.Never); // FAILS! Invoked Times.Once.
await SendDataAsync(1, Array.Empty<byte>(), endStream: true);
var headersFrame = await ExpectAsync(Http2FrameType.HEADERS,
withLength: 32,
withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS,
withStreamId: 1);
_hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this);
Assert.Equal(2, _decodedHeaders.Count);
Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
Assert.Equal("200", _decodedHeaders[HeaderNames.Status]);
var dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 1,
withFlags: (byte)Http2DataFrameFlags.NONE,
withStreamId: 1);
Assert.Equal(0x01, dataFrame.Payload.Span[0]);
dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 0,
withFlags: (byte)Http2DataFrameFlags.END_STREAM,
withStreamId: 1);
await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
}
public async Task ExtendedCONNET_AcceptAsyncStream_IsNotLimitedByMaxRequestBodySize()
{
var limits = _serviceContext.ServerOptions.Limits;
// We're going to send more than the MaxRequestBodySize bytes from the client to the server over the connection
// Since this is not a request body, this should be allowed like it would be for an upgraded connection.
limits.MaxRequestBodySize = 5;
await InitializeConnectionAsync(async context =>
{
var connectFeature = context.Features.Get<IHttpExtendedConnectFeature>();
var maxRequestBodySizeFeature = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
// Extended connects don't have a meaningful request body to limit. It doesn't make sense for this value to change just because AcceptAsync() was called.
Assert.True(maxRequestBodySizeFeature.IsReadOnly); // FAILS!
Assert.Null(maxRequestBodySizeFeature.MaxRequestBodySize); // FAILS!
var stream = await connectFeature.AcceptAsync();
// NOW it has the right value, but it should have had it before.
Assert.True(maxRequestBodySizeFeature.IsReadOnly);
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream); // FAILS! because the client sent more than the MaxRequestBodySize but it's not a body.
Assert.Equal(_serviceContext.ServerOptions.Limits.MaxRequestBodySize + 1, memoryStream.Length);
await stream.WriteAsync(new byte[] { 0x01 });
});
var headers = new[]
{
new KeyValuePair<string, string>(HeaderNames.Method, "CONNECT"),
new KeyValuePair<string, string>(HeaderNames.Protocol, "websocket"),
new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
new KeyValuePair<string, string>(HeaderNames.Path, "/chat"),
new KeyValuePair<string, string>(HeaderNames.Authority, "server.example.com"),
new KeyValuePair<string, string>(HeaderNames.WebSocketSubProtocols, "chat, superchat"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketExtensions, "permessage-deflate"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketVersion, "13"),
new KeyValuePair<string, string>(HeaderNames.Origin, "http://www.example.com"),
};
await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, headers);
await SendDataAsync(1, new byte[(int)limits.MaxRequestBodySize + 1], endStream: true);
var headersFrame = await ExpectAsync(Http2FrameType.HEADERS,
withLength: 32,
withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS,
withStreamId: 1);
_hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this);
Assert.Equal(2, _decodedHeaders.Count);
Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
Assert.Equal("200", _decodedHeaders[HeaderNames.Status]);
var dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 1,
withFlags: (byte)Http2DataFrameFlags.NONE,
withStreamId: 1);
Assert.Equal(0x01, dataFrame.Payload.Span[0]); // FAILS! because Response.Body.WriteAsync wrote 0x00 instead of 0x01.
dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 0,
withFlags: (byte)Http2DataFrameFlags.END_STREAM,
withStreamId: 1);
await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
}
[Fact]
public async Task HEADERS_Received_ExtendedCONNECTMethod_DoesNotProvideUsableBodyStreams()
{
await InitializeConnectionAsync(async context =>
{
var connectFeature = context.Features.Get<IHttpExtendedConnectFeature>();
// The below should really throw instead, but nooping is better than our current Response.Body behavior.
Assert.Equal(0, await context.Request.Body.ReadAsync(new byte[1]));
// Tell me this doesn't get sent in a DATA frame with a 200 status code!!!
// This will be interpreted as a valid data from the server to client over the connection rather than a normal response.
await Assert.ThrowsAsync<InvalidOperationException>(() => context.Response.Body.WriteAsync(new byte[1] { 0x00 })); // FAILS!
var stream = await connectFeature.AcceptAsync();
await stream.WriteAsync(new byte[] { 0x01 });
});
var headers = new[]
{
new KeyValuePair<string, string>(HeaderNames.Method, "CONNECT"),
new KeyValuePair<string, string>(HeaderNames.Protocol, "websocket"),
new KeyValuePair<string, string>(HeaderNames.Scheme, "http"),
new KeyValuePair<string, string>(HeaderNames.Path, "/chat"),
new KeyValuePair<string, string>(HeaderNames.Authority, "server.example.com"),
new KeyValuePair<string, string>(HeaderNames.WebSocketSubProtocols, "chat, superchat"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketExtensions, "permessage-deflate"),
new KeyValuePair<string, string>(HeaderNames.SecWebSocketVersion, "13"),
new KeyValuePair<string, string>(HeaderNames.Origin, "http://www.example.com"),
};
await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, headers);
await SendDataAsync(1, new byte[(int)_serviceContext.ServerOptions.Limits.MaxRequestBodySize + 1], endStream: true);
var headersFrame = await ExpectAsync(Http2FrameType.HEADERS,
withLength: 32,
withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS,
withStreamId: 1);
_hpackDecoder.Decode(headersFrame.PayloadSequence, endHeaders: false, handler: this);
Assert.Equal(2, _decodedHeaders.Count);
Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase);
Assert.Equal("200", _decodedHeaders[HeaderNames.Status]);
var dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 1,
withFlags: (byte)Http2DataFrameFlags.NONE,
withStreamId: 1);
Assert.Equal(0x01, dataFrame.Payload.Span[0]); // FAILS! because Response.Body.WriteAsync wrote 0x00 instead of 0x01.
dataFrame = await ExpectAsync(Http2FrameType.DATA,
withLength: 0,
withFlags: (byte)Http2DataFrameFlags.END_STREAM,
withStreamId: 1);
await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false);
}
@Tratcher
Thanks for contacting us.
We're moving this issue to the .NET 7 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.
- [x] MaxRequestBodySize - Preview 6
- [x] MinRequestBodyDataRate - Preview 6
- [ ] DoesNotProvideUsableBodyStreams https://github.com/dotnet/aspnetcore/issues/43697
I've split the remaining work to a different issue to better reflect what's left and the milestones where the work will/has happen.