From 9dfffd14bb71e4ebef4d0166e0f01006bf58b6cd Mon Sep 17 00:00:00 2001 From: Cesar Blum Silveira Date: Thu, 12 Oct 2017 17:26:20 -0700 Subject: [PATCH] HTTP/2: support trailers. --- .../Internal/Http2/Http2Connection.cs | 58 ++++- .../Http2ConnectionTests.cs | 219 ++++++++++++++++++ 2 files changed, 269 insertions(+), 8 deletions(-) diff --git a/src/Kestrel.Core/Internal/Http2/Http2Connection.cs b/src/Kestrel.Core/Internal/Http2/Http2Connection.cs index 40d35ebee3..82637d51c1 100644 --- a/src/Kestrel.Core/Internal/Http2/Http2Connection.cs +++ b/src/Kestrel.Core/Internal/Http2/Http2Connection.cs @@ -358,13 +358,24 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 // END_STREAM flag set MUST treat that as a connection error (Section 5.4.1) // of type STREAM_CLOSED, unless the frame is permitted as described below. // - // (The allowed frame types for this situation are WINDOW_UPDATE, RST_STREAM and PRIORITY) + // (The allowed frame types after END_STREAM are WINDOW_UPDATE, RST_STREAM and PRIORITY) if (stream.EndStreamReceived) { throw new Http2ConnectionErrorException(CoreStrings.FormatHttp2ErrorStreamHalfClosedRemote(_incomingFrame.Type, stream.StreamId), Http2ErrorCode.STREAM_CLOSED); } - // TODO: trailers + // This is the last chance for the client to send END_STREAM + if ((_incomingFrame.HeadersFlags & Http2HeadersFrameFlags.END_STREAM) == 0) + { + throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorHeadersWithTrailersNoEndStream, Http2ErrorCode.PROTOCOL_ERROR); + } + + // Since we found an active stream, this HEADERS frame contains trailers + _currentHeadersStream = stream; + _requestHeaderParsingState = RequestHeaderParsingState.Trailers; + + var endHeaders = (_incomingFrame.HeadersFlags & Http2HeadersFrameFlags.END_HEADERS) == Http2HeadersFrameFlags.END_HEADERS; + await DecodeTrailersAsync(endHeaders, _incomingFrame.HeadersPayload); } else if (_incomingFrame.StreamId <= _highestOpenedStreamId) { @@ -585,7 +596,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 var endHeaders = (_incomingFrame.ContinuationFlags & Http2ContinuationFrameFlags.END_HEADERS) == Http2ContinuationFrameFlags.END_HEADERS; - return DecodeHeadersAsync(endHeaders, _incomingFrame.Payload); + if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) + { + return DecodeTrailersAsync(endHeaders, _incomingFrame.Payload); + } + else + { + return DecodeHeadersAsync(endHeaders, _incomingFrame.Payload); + } } private Task ProcessUnknownFrameAsync() @@ -620,6 +638,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 return Task.CompletedTask; } + private Task DecodeTrailersAsync(bool endHeaders, Span payload) + { + _hpackDecoder.Decode(payload, endHeaders, handler: this); + + if (endHeaders) + { + var endStreamTask = _currentHeadersStream.OnDataAsync(Constants.EmptyData, endStream: true); + ResetRequestHeaderParsingState(); + return endStreamTask; + } + + return Task.CompletedTask; + } + private void StartStream() { if (!_isMethodConnect && (_parsedPseudoHeaderFields & _mandatoryRequestPseudoHeaderFields) != _mandatoryRequestPseudoHeaderFields) @@ -682,17 +714,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 // http://httpwg.org/specs/rfc7540.html#rfc.section.8.1.2.1 if (IsPseudoHeaderField(name, out var headerField)) { - if (_requestHeaderParsingState == RequestHeaderParsingState.Headers || - _requestHeaderParsingState == RequestHeaderParsingState.Trailers) + if (_requestHeaderParsingState == RequestHeaderParsingState.Headers) { - // Pseudo-header fields MUST NOT appear in trailers. - // ... // All pseudo-header fields MUST appear in the header block before regular header fields. // Any request or response that contains a pseudo-header field that appears in a header // block after a regular header field MUST be treated as malformed (Section 8.1.2.6). throw new Http2StreamErrorException(_currentHeadersStream.StreamId, CoreStrings.Http2ErrorPseudoHeaderFieldAfterRegularHeaders, Http2ErrorCode.PROTOCOL_ERROR); } + if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) + { + // Pseudo-header fields MUST NOT appear in trailers. + throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorTrailersContainPseudoHeaderField, Http2ErrorCode.PROTOCOL_ERROR); + } + _requestHeaderParsingState = RequestHeaderParsingState.PseudoHeaderFields; if (headerField == PseudoHeaderFields.Unknown) @@ -739,7 +774,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { if (name[i] >= 65 && name[i] <= 90) { - throw new Http2StreamErrorException(_currentHeadersStream.StreamId, CoreStrings.Http2ErrorHeaderNameUppercase, Http2ErrorCode.PROTOCOL_ERROR); + if (_requestHeaderParsingState == RequestHeaderParsingState.Trailers) + { + throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorTrailerNameUppercase, Http2ErrorCode.PROTOCOL_ERROR); + } + else + { + throw new Http2StreamErrorException(_currentHeadersStream.StreamId, CoreStrings.Http2ErrorHeaderNameUppercase, Http2ErrorCode.PROTOCOL_ERROR); + } } } } diff --git a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs index 40fdf47094..39db80b4a4 100644 --- a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs +++ b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.IO.Pipelines; using System.Linq; using System.Text; @@ -44,6 +45,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests new KeyValuePair("upgrade-insecure-requests", "1"), }; + private static readonly IEnumerable> _requestTrailers = new[] + { + new KeyValuePair("trailer-one", "1"), + new KeyValuePair("trailer-two", "2"), + }; + private static readonly IEnumerable> _oneContinuationRequestHeaders = new[] { new KeyValuePair(":method", "GET"), @@ -93,6 +100,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests private readonly RequestDelegate _noopApplication; private readonly RequestDelegate _readHeadersApplication; + private readonly RequestDelegate _readTrailersApplication; private readonly RequestDelegate _bufferingApplication; private readonly RequestDelegate _echoApplication; private readonly RequestDelegate _echoWaitForAbortApplication; @@ -118,6 +126,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests return Task.CompletedTask; }; + _readTrailersApplication = async context => + { + using (var ms = new MemoryStream()) + { + // Consuming the entire request body guarantees trailers will be available + await context.Request.Body.CopyToAsync(ms); + } + + foreach (var header in context.Request.Headers) + { + _receivedHeaders[header.Key] = header.Value.ToString(); + } + }; + _bufferingApplication = async context => { var data = new List(); @@ -687,6 +709,52 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task HEADERS_Received_WithTrailers_Decoded(bool sendData) + { + await InitializeConnectionAsync(_readTrailersApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + + // Initialize another stream with a higher stream ID, and verify that after trailers are + // decoded by the other stream, the highest opened stream ID is not reset to the lower ID + // (the highest opened stream ID is sent by the server in the GOAWAY frame when shutting + // down the connection). + await SendHeadersAsync(3, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, _browserRequestHeaders); + + // The second stream should end first, since the first one is waiting for the request body. + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 3); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 3); + + if (sendData) + { + await SendDataAsync(1, _helloBytes, endStream: false); + } + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, _requestTrailers); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + VerifyDecodedRequestHeaders(_browserRequestHeaders.Concat(_requestTrailers)); + + await StopConnectionAsync(expectedLastStreamId: 3, ignoreNonGoAwayFrames: false); + } + [Fact] public async Task HEADERS_Received_StreamIdZero_ConnectionError() { @@ -861,6 +929,39 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests expectedErrorMessage: CoreStrings.HPackErrorIncompleteHeaderBlock); } + [Theory] + [MemberData(nameof(IllegalTrailerData))] + public async Task HEADERS_Received_WithTrailers_ContainsIllegalTrailer_ConnectionError(byte[] trailers, string expectedErrorMessage) + { + await InitializeConnectionAsync(_readTrailersApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, trailers); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: 1, + expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, + expectedErrorMessage: expectedErrorMessage); + } + + [Theory] + [InlineData(Http2HeadersFrameFlags.NONE)] + [InlineData(Http2HeadersFrameFlags.END_HEADERS)] + public async Task HEADERS_Received_WithTrailers_EndStreamNotSet_ConnectionError(Http2HeadersFrameFlags flags) + { + await InitializeConnectionAsync(_readTrailersApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + await SendHeadersAsync(1, flags, _requestTrailers); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: 1, + expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, + expectedErrorMessage: CoreStrings.Http2ErrorHeadersWithTrailersNoEndStream); + } + [Theory] [MemberData(nameof(UpperCaseHeaderNameData))] public async Task HEADERS_Received_HeaderNameContainsUpperCaseCharacter_StreamError(byte[] headerBlock) @@ -1562,6 +1663,67 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CONTINUATION_Received_WithTrailers_Decoded(bool sendData) + { + await InitializeConnectionAsync(_readTrailersApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + + // Initialize another stream with a higher stream ID, and verify that after trailers are + // decoded by the other stream, the highest opened stream ID is not reset to the lower ID + // (the highest opened stream ID is sent by the server in the GOAWAY frame when shutting + // down the connection). + await SendHeadersAsync(3, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, _browserRequestHeaders); + + // The second stream should end first, since the first one is waiting for the request body. + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 3); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 3); + + if (sendData) + { + await SendDataAsync(1, _helloBytes, endStream: false); + } + + // Trailers encoded as Literal Header Field without Indexing - New Name + // trailer-1: 1 + // trailer-2: 2 + var trailers = new byte[] { 0x00, 0x09 } + .Concat(Encoding.ASCII.GetBytes("trailer-1")) + .Concat(new byte[] { 0x01, (byte)'1' }) + .Concat(new byte[] { 0x00, 0x09 }) + .Concat(Encoding.ASCII.GetBytes("trailer-2")) + .Concat(new byte[] { 0x01, (byte)'2' }) + .ToArray(); + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, new byte[0]); + await SendContinuationAsync(1, Http2ContinuationFrameFlags.END_HEADERS, trailers); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + VerifyDecodedRequestHeaders(_browserRequestHeaders.Concat(new[] + { + new KeyValuePair("trailer-1", "1"), + new KeyValuePair("trailer-2", "2") + })); + + await StopConnectionAsync(expectedLastStreamId: 3, ignoreNonGoAwayFrames: false); + } + [Fact] public async Task CONTINUATION_Received_StreamIdMismatch_ConnectionError() { @@ -1592,6 +1754,23 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests expectedErrorMessage: CoreStrings.HPackErrorIncompleteHeaderBlock); } + [Theory] + [MemberData(nameof(IllegalTrailerData))] + public async Task CONTINUATION_Received_WithTrailers_ContainsIllegalTrailer_ConnectionError(byte[] trailers, string expectedErrorMessage) + { + await InitializeConnectionAsync(_readTrailersApplication); + + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS, _browserRequestHeaders); + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_STREAM, new byte[0]); + await SendContinuationAsync(1, Http2ContinuationFrameFlags.END_HEADERS, trailers); + + await WaitForConnectionErrorAsync( + ignoreNonGoAwayFrames: false, + expectedLastStreamId: 1, + expectedErrorCode: Http2ErrorCode.PROTOCOL_ERROR, + expectedErrorMessage: expectedErrorMessage); + } + [Theory] [MemberData(nameof(MissingPseudoHeaderFieldData))] public async Task CONTINUATION_Received_HeaderBlockDoesNotContainMandatoryPseudoHeaderField_StreamError(IEnumerable> headers) @@ -2048,6 +2227,17 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests return done; } + private async Task SendContinuationAsync(int streamId, Http2ContinuationFrameFlags flags, byte[] payload) + { + var frame = new Http2Frame(); + + frame.PrepareContinuation(flags, streamId); + frame.Length = payload.Length; + payload.CopyTo(frame.Payload); + + await SendAsync(frame.Raw); + } + private Task SendEmptyContinuationFrameAsync(int streamId, Http2ContinuationFrameFlags flags) { var frame = new Http2Frame(); @@ -2463,5 +2653,34 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests return data; } } + + public static TheoryData IllegalTrailerData + { + get + { + // We can't use HPackEncoder here because it will convert header names to lowercase + var data = new TheoryData(); + + // Indexed Header Field - :method: GET + data.Add(new byte[] { 0x82 }, CoreStrings.Http2ErrorTrailersContainPseudoHeaderField); + + // Indexed Header Field - :path: / + data.Add(new byte[] { 0x84 }, CoreStrings.Http2ErrorTrailersContainPseudoHeaderField); + + // Indexed Header Field - :scheme: http + data.Add(new byte[] { 0x86 }, CoreStrings.Http2ErrorTrailersContainPseudoHeaderField); + + // Literal Header Field without Indexing - Indexed Name - :authority: 127.0.0.1 + data.Add(new byte[] { 0x01, 0x09 }.Concat(Encoding.ASCII.GetBytes("127.0.0.1")).ToArray(), CoreStrings.Http2ErrorTrailersContainPseudoHeaderField); + + // Literal Header Field without Indexing - New Name - contains-Uppercase: 0 + data.Add(new byte[] { 0x00, 0x12 } + .Concat(Encoding.ASCII.GetBytes("contains-Uppercase")) + .Concat(new byte[] { 0x01, (byte)'0' }) + .ToArray(), CoreStrings.Http2ErrorTrailerNameUppercase); + + return data; + } + } } }