From 612fcca7291e5fad753fccbd8b15279c21e2e288 Mon Sep 17 00:00:00 2001 From: "Chris Ross (ASP.NET)" Date: Tue, 17 Jul 2018 16:25:58 -0700 Subject: [PATCH] Validate Http/2 pseudo headers #2205 #2263 #2659 --- .../Kestrel.Performance/Mocks/MockTrace.cs | 2 + src/Kestrel.Core/CoreStrings.resx | 12 + .../Internal/Http/Http1Connection.cs | 69 +- .../Internal/Http/PathNormalizer.cs | 50 + .../Internal/Http2/Http2Stream.cs | 213 ++- .../Internal/Infrastructure/HttpUtilities.cs | 36 +- .../Internal/Infrastructure/IKestrelTrace.cs | 3 + .../Internal/Infrastructure/KestrelTrace.cs | 10 + .../Properties/CoreStrings.Designer.cs | 56 + .../Http2ConnectionTests.cs | 576 +------- test/Kestrel.Core.Tests/Http2StreamTests.cs | 1190 +++++++++++++++++ test/Kestrel.Core.Tests/HttpUtilitiesTest.cs | 5 +- .../Http2/H2SpecTests.cs | 2 +- test/shared/CompositeKestrelTrace.cs | 7 + 14 files changed, 1536 insertions(+), 695 deletions(-) create mode 100644 test/Kestrel.Core.Tests/Http2StreamTests.cs diff --git a/benchmarks/Kestrel.Performance/Mocks/MockTrace.cs b/benchmarks/Kestrel.Performance/Mocks/MockTrace.cs index 8386402a78..b14fd2e14f 100644 --- a/benchmarks/Kestrel.Performance/Mocks/MockTrace.cs +++ b/benchmarks/Kestrel.Performance/Mocks/MockTrace.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; @@ -48,5 +49,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Performance public void Http2ConnectionError(string connectionId, Http2ConnectionErrorException ex) { } public void Http2StreamError(string connectionId, Http2StreamErrorException ex) { } public void HPackDecodingError(string connectionId, int streamId, HPackDecodingException ex) { } + public void Http2StreamResetAbort(string traceIdentifier, Http2ErrorCode error, ConnectionAbortedException abortReason) { } } } diff --git a/src/Kestrel.Core/CoreStrings.resx b/src/Kestrel.Core/CoreStrings.resx index 7ef91a6b83..6ef16c78f0 100644 --- a/src/Kestrel.Core/CoreStrings.resx +++ b/src/Kestrel.Core/CoreStrings.resx @@ -545,4 +545,16 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l The request stream was aborted. + + CONNECT requests must not send :scheme or :path headers. + + + The Method '{method}' is invalid. + + + The request :path is invalid: '{path}' + + + The request :scheme header '{requestScheme}' does not match the transport scheme '{transportScheme}'. + \ No newline at end of file diff --git a/src/Kestrel.Core/Internal/Http/Http1Connection.cs b/src/Kestrel.Core/Internal/Http/Http1Connection.cs index 849b3b1ce8..af32fdf01d 100644 --- a/src/Kestrel.Core/Internal/Http/Http1Connection.cs +++ b/src/Kestrel.Core/Internal/Http/Http1Connection.cs @@ -204,6 +204,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http Debug.Assert(HttpVersion != null, "HttpVersion was not set"); } + // Compare with Http2Stream.TryValidatePseudoHeaders private void OnOriginFormTarget(HttpMethod method, HttpVersion version, Span target, Span path, Span query, Span customMethod, bool pathEncoded) { Debug.Assert(target[0] == ByteForwardSlash, "Should only be called when path starts with /"); @@ -213,59 +214,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // URIs are always encoded/escaped to ASCII https://tools.ietf.org/html/rfc3986#page-11 // Multibyte Internationalized Resource Identifiers (IRIs) are first converted to utf8; // then encoded/escaped to ASCII https://www.ietf.org/rfc/rfc3987.txt "Mapping of IRIs to URIs" - string requestUrlPath = null; - string rawTarget = null; try { // Read raw target before mutating memory. - rawTarget = target.GetAsciiStringNonNullCharacters(); - - if (pathEncoded) - { - // URI was encoded, unescape and then parse as UTF-8 - // Disabling warning temporary - var pathLength = UrlDecoder.Decode(path, path); - - // Removing dot segments must be done after unescaping. From RFC 3986: - // - // URI producing applications should percent-encode data octets that - // correspond to characters in the reserved set unless these characters - // are specifically allowed by the URI scheme to represent data in that - // component. If a reserved character is found in a URI component and - // no delimiting role is known for that character, then it must be - // interpreted as representing the data octet corresponding to that - // character's encoding in US-ASCII. - // - // https://tools.ietf.org/html/rfc3986#section-2.2 - pathLength = PathNormalizer.RemoveDotSegments(path.Slice(0, pathLength)); - - requestUrlPath = GetUtf8String(path.Slice(0, pathLength)); - } - else - { - var pathLength = PathNormalizer.RemoveDotSegments(path); - - if (path.Length == pathLength && query.Length == 0) - { - // If no decoding was required, no dot segments were removed and - // there is no query, the request path is the same as the raw target - requestUrlPath = rawTarget; - } - else - { - requestUrlPath = path.Slice(0, pathLength).GetAsciiStringNonNullCharacters(); - } - } + RawTarget = target.GetAsciiStringNonNullCharacters(); + QueryString = query.GetAsciiStringNonNullCharacters(); + Path = PathNormalizer.DecodePath(path, pathEncoded, RawTarget, query.Length); } catch (InvalidOperationException) { ThrowRequestTargetRejected(target); } - - QueryString = query.GetAsciiStringNonNullCharacters(); - RawTarget = rawTarget; - Path = requestUrlPath; } private void OnAuthorityFormTarget(HttpMethod method, Span target) @@ -346,16 +306,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http QueryString = query.GetAsciiStringNonNullCharacters(); } - private static unsafe string GetUtf8String(Span path) - { - // .NET 451 doesn't have pointer overloads for Encoding.GetString so we - // copy to an array - fixed (byte* pointer = &MemoryMarshal.GetReference(path)) - { - return Encoding.UTF8.GetString(pointer, path.Length); - } - } - internal void EnsureHostHeaderExists() { // https://tools.ietf.org/html/rfc7230#section-5.4 @@ -383,10 +333,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http // Tail call ValidateNonOrginHostHeader(hostText); } - else + else if (!HttpUtilities.IsHostHeaderValid(hostText)) { - // Tail call - HttpUtilities.ValidateHostHeader(hostText); + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); } } @@ -418,8 +367,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http } } - // Tail call - HttpUtilities.ValidateHostHeader(hostText); + if (!HttpUtilities.IsHostHeaderValid(hostText)) + { + BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + } } protected override void OnReset() diff --git a/src/Kestrel.Core/Internal/Http/PathNormalizer.cs b/src/Kestrel.Core/Internal/Http/PathNormalizer.cs index 68cdddf7ce..135a55ab25 100644 --- a/src/Kestrel.Core/Internal/Http/PathNormalizer.cs +++ b/src/Kestrel.Core/Internal/Http/PathNormalizer.cs @@ -4,6 +4,9 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Text; +using Microsoft.AspNetCore.Connections.Abstractions; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http { @@ -12,6 +15,53 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http private const byte ByteSlash = (byte)'/'; private const byte ByteDot = (byte)'.'; + public static string DecodePath(Span path, bool pathEncoded, string rawTarget, int queryLength) + { + int pathLength; + if (pathEncoded) + { + // URI was encoded, unescape and then parse as UTF-8 + // Disabling warning temporary + pathLength = UrlDecoder.DecodeInPlace(path); + + // Removing dot segments must be done after unescaping. From RFC 3986: + // + // URI producing applications should percent-encode data octets that + // correspond to characters in the reserved set unless these characters + // are specifically allowed by the URI scheme to represent data in that + // component. If a reserved character is found in a URI component and + // no delimiting role is known for that character, then it must be + // interpreted as representing the data octet corresponding to that + // character's encoding in US-ASCII. + // + // https://tools.ietf.org/html/rfc3986#section-2.2 + pathLength = RemoveDotSegments(path.Slice(0, pathLength)); + + return GetUtf8String(path.Slice(0, pathLength)); + } + + pathLength = RemoveDotSegments(path); + + if (path.Length == pathLength && queryLength == 0) + { + // If no decoding was required, no dot segments were removed and + // there is no query, the request path is the same as the raw target + return rawTarget; + } + + return path.Slice(0, pathLength).GetAsciiStringNonNullCharacters(); + } + + private static unsafe string GetUtf8String(Span path) + { + // .NET 451 doesn't have pointer overloads for Encoding.GetString so we + // copy to an array + fixed (byte* pointer = &MemoryMarshal.GetReference(path)) + { + return Encoding.UTF8.GetString(pointer, path.Length); + } + } + // In-place implementation of the algorithm from https://tools.ietf.org/html/rfc3986#section-5.2.4 public static unsafe int RemoveDotSegments(Span input) { diff --git a/src/Kestrel.Core/Internal/Http2/Http2Stream.cs b/src/Kestrel.Core/Internal/Http2/Http2Stream.cs index 5b52546f20..85cb88d5da 100644 --- a/src/Kestrel.Core/Internal/Http2/Http2Stream.cs +++ b/src/Kestrel.Core/Internal/Http2/Http2Stream.cs @@ -3,13 +3,18 @@ using System; using System.Buffers; +using System.Diagnostics; using System.IO; using System.IO.Pipelines; +using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Connections.Abstractions; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 @@ -55,71 +60,192 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 protected override MessageBody CreateMessageBody() => Http2MessageBody.For(HttpRequestHeaders, this); + // Compare to Http1Connection.OnStartLine protected override bool TryParseRequest(ReadResult result, out bool endConnection) { // We don't need any of the parameters because we don't implement BeginRead to actually // do the reading from a pipeline, nor do we use endConnection to report connection-level errors. + endConnection = !TryValidatePseudoHeaders(); + return true; + } + + private bool TryValidatePseudoHeaders() + { + // The initial pseudo header validation takes place in Http2Connection.ValidateHeader and StartStream + // They make sure the right fields are at least present (except for Connect requests) exactly once. _httpVersion = Http.HttpVersion.Http2; - var methodText = RequestHeaders[HeaderNames.Method]; - Method = HttpUtilities.GetKnownMethod(methodText); - _methodText = methodText; - if (!string.Equals(RequestHeaders[HeaderNames.Scheme], Scheme, StringComparison.OrdinalIgnoreCase)) + + if (!TryValidateMethod()) { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidRequestLine); + return false; } - var path = RequestHeaders[HeaderNames.Path].ToString(); - var queryIndex = path.IndexOf('?'); + if (!TryValidateAuthorityAndHost(out var hostText)) + { + return false; + } - Path = queryIndex == -1 ? path : path.Substring(0, queryIndex); - QueryString = queryIndex == -1 ? string.Empty : path.Substring(queryIndex); + // CONNECT - :scheme and :path must be excluded + if (Method == HttpMethod.Connect) + { + if (!String.IsNullOrEmpty(RequestHeaders[HeaderNames.Scheme]) || !String.IsNullOrEmpty(RequestHeaders[HeaderNames.Path])) + { + ResetAndAbort(new ConnectionAbortedException(CoreStrings.Http2ErrorConnectMustNotSendSchemeOrPath), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + + RawTarget = hostText; + + return true; + } + + // :scheme https://tools.ietf.org/html/rfc7540#section-8.1.2.3 + // ":scheme" is not restricted to "http" and "https" schemed URIs. A + // proxy or gateway can translate requests for non - HTTP schemes, + // enabling the use of HTTP to interact with non - HTTP services. + + // - That said, we shouldn't allow arbitrary values or use them to populate Request.Scheme, right? + // - For now we'll restrict it to http/s and require it match the transport. + // - We'll need to find some concrete scenarios to warrant unblocking this. + if (!string.Equals(RequestHeaders[HeaderNames.Scheme], Scheme, StringComparison.OrdinalIgnoreCase)) + { + ResetAndAbort(new ConnectionAbortedException( + CoreStrings.FormatHttp2StreamErrorSchemeMismatch(RequestHeaders[HeaderNames.Scheme], Scheme)), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + + // :path (and query) - Required + // Must start with / except may be * for OPTIONS + var path = RequestHeaders[HeaderNames.Path].ToString(); RawTarget = path; + // OPTIONS - https://tools.ietf.org/html/rfc7540#section-8.1.2.3 + // This pseudo-header field MUST NOT be empty for "http" or "https" + // URIs; "http" or "https" URIs that do not contain a path component + // MUST include a value of '/'. The exception to this rule is an + // OPTIONS request for an "http" or "https" URI that does not include + // a path component; these MUST include a ":path" pseudo-header field + // with a value of '*'. + if (Method == HttpMethod.Options && path.Length == 1 && path[0] == '*') + { + // * is stored in RawTarget only since HttpRequest expects Path to be empty or start with a /. + Path = string.Empty; + QueryString = string.Empty; + return true; + } + + var queryIndex = path.IndexOf('?'); + QueryString = queryIndex == -1 ? string.Empty : path.Substring(queryIndex); + + var pathSegment = queryIndex == -1 ? path.AsSpan() : path.AsSpan(0, queryIndex); + + return TryValidatePath(pathSegment); + } + + private bool TryValidateMethod() + { + // :method + _methodText = RequestHeaders[HeaderNames.Method].ToString(); + Method = HttpUtilities.GetKnownMethod(_methodText); + + if (Method == HttpMethod.None) + { + ResetAndAbort(new ConnectionAbortedException(CoreStrings.FormatHttp2ErrorMethodInvalid(_methodText)), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + + if (Method == HttpMethod.Custom) + { + if (HttpCharacters.IndexOfInvalidTokenChar(_methodText) >= 0) + { + ResetAndAbort(new ConnectionAbortedException(CoreStrings.FormatHttp2ErrorMethodInvalid(_methodText)), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + } + + return true; + } + + private bool TryValidateAuthorityAndHost(out string hostText) + { + // :authority (optional) + // Prefer this over Host + + var authority = RequestHeaders[HeaderNames.Authority]; + var host = HttpRequestHeaders.HeaderHost; + if (!StringValues.IsNullOrEmpty(authority)) + { + // https://tools.ietf.org/html/rfc7540#section-8.1.2.3 + // Clients that generate HTTP/2 requests directly SHOULD use the ":authority" + // pseudo - header field instead of the Host header field. + // An intermediary that converts an HTTP/2 request to HTTP/1.1 MUST + // create a Host header field if one is not present in a request by + // copying the value of the ":authority" pseudo - header field. + + // We take this one step further, we don't want mismatched :authority + // and Host headers, replace Host if :authority is defined. The application + // will operate on the Host header. + HttpRequestHeaders.HeaderHost = authority; + host = authority; + } + // https://tools.ietf.org/html/rfc7230#section-5.4 // A server MUST respond with a 400 (Bad Request) status code to any // HTTP/1.1 request message that lacks a Host header field and to any // request message that contains more than one Host header field or a // Host header field with an invalid field-value. - - var authority = RequestHeaders[HeaderNames.Authority]; - var host = HttpRequestHeaders.HeaderHost; - if (authority.Count > 0) + hostText = host.ToString(); + if (host.Count > 1 || !HttpUtilities.IsHostHeaderValid(hostText)) { - // https://tools.ietf.org/html/rfc7540#section-8.1.2.3 - // An intermediary that converts an HTTP/2 request to HTTP/1.1 MUST - // create a Host header field if one is not present in a request by - // copying the value of the ":authority" pseudo - header field. - // - // We take this one step further, we don't want mismatched :authority - // and Host headers, replace Host if :authority is defined. - HttpRequestHeaders.HeaderHost = authority; - host = authority; + // RST replaces 400 + ResetAndAbort(new ConnectionAbortedException(CoreStrings.FormatBadRequest_InvalidHostHeader_Detail(hostText)), Http2ErrorCode.PROTOCOL_ERROR); + return false; } - // TODO: OPTIONS * requests? - // To ensure that the HTTP / 1.1 request line can be reproduced - // accurately, this pseudo - header field MUST be omitted when - // translating from an HTTP/ 1.1 request that has a request target in - // origin or asterisk form(see[RFC7230], Section 5.3). - // https://tools.ietf.org/html/rfc7230#section-5.3 - - if (host.Count <= 0) - { - BadHttpRequestException.Throw(RequestRejectionReason.MissingHostHeader); - } - else if (host.Count > 1) - { - BadHttpRequestException.Throw(RequestRejectionReason.MultipleHostHeaders); - } - - var hostText = host.ToString(); - HttpUtilities.ValidateHostHeader(hostText); - - endConnection = false; return true; } + private bool TryValidatePath(ReadOnlySpan pathSegment) + { + // Must start with a leading slash + if (pathSegment.Length == 0 || pathSegment[0] != '/') + { + ResetAndAbort(new ConnectionAbortedException(CoreStrings.FormatHttp2StreamErrorPathInvalid(RawTarget)), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + + var pathEncoded = pathSegment.IndexOf('%') >= 0; + + // Compare with Http1Connection.OnOriginFormTarget + + // URIs are always encoded/escaped to ASCII https://tools.ietf.org/html/rfc3986#page-11 + // Multibyte Internationalized Resource Identifiers (IRIs) are first converted to utf8; + // then encoded/escaped to ASCII https://www.ietf.org/rfc/rfc3987.txt "Mapping of IRIs to URIs" + + try + { + // The decoder operates only on raw bytes + var pathBuffer = new byte[pathSegment.Length].AsSpan(); + for (int i = 0; i < pathSegment.Length; i++) + { + var ch = pathSegment[i]; + // The header parser should already be checking this + Debug.Assert(32 < ch && ch < 127); + pathBuffer[i] = (byte)ch; + } + + Path = PathNormalizer.DecodePath(pathBuffer, pathEncoded, RawTarget, QueryString.Length); + + return true; + } + catch (InvalidOperationException) + { + ResetAndAbort(new ConnectionAbortedException(CoreStrings.FormatHttp2StreamErrorPathInvalid(RawTarget)), Http2ErrorCode.PROTOCOL_ERROR); + return false; + } + } + public async Task OnDataAsync(ArraySegment data, bool endStream) { // TODO: content-length accounting @@ -164,7 +290,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 protected override void ApplicationAbort() { - Log.ApplicationAbortedConnection(ConnectionId, TraceIdentifier); var abortReason = new ConnectionAbortedException(CoreStrings.ConnectionAbortedByApplication); ResetAndAbort(abortReason, Http2ErrorCode.CANCEL); } @@ -176,6 +301,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 return; } + Log.Http2StreamResetAbort(TraceIdentifier, error, abortReason); + // Don't block on IO. This never faults. _ = _http2Output.WriteRstStreamAsync(error); diff --git a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs index d4d807aa13..6ee60b5018 100644 --- a/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs +++ b/src/Kestrel.Core/Internal/Infrastructure/HttpUtilities.cs @@ -191,13 +191,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure // Called by http/2 if (value == null) { - throw new ArgumentNullException(nameof(value)); + return HttpMethod.None; } var length = value.Length; if (length == 0) { - throw new ArgumentException(nameof(value)); + return HttpMethod.None; } // Start with custom and assign if known method is found @@ -395,39 +395,41 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure } } - public static void ValidateHostHeader(string hostText) + public static bool IsHostHeaderValid(string hostText) { if (string.IsNullOrEmpty(hostText)) { // The spec allows empty values - return; + return true; } var firstChar = hostText[0]; if (firstChar == '[') { // Tail call - ValidateIPv6Host(hostText); + return IsIPv6HostValid(hostText); } else { if (firstChar == ':') { // Only a port - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } var invalid = HttpCharacters.IndexOfInvalidHostChar(hostText); if (invalid >= 0) { // Tail call - ValidateHostPort(hostText, invalid); + return IsHostPortValid(hostText, invalid); } + + return true; } } // The lead '[' was already checked - private static void ValidateIPv6Host(string hostText) + private static bool IsIPv6HostValid(string hostText) { for (var i = 1; i < hostText.Length; i++) { @@ -437,43 +439,45 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure // [::1] is the shortest valid IPv6 host if (i < 4) { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } else if (i + 1 < hostText.Length) { // Tail call - ValidateHostPort(hostText, i + 1); + return IsHostPortValid(hostText, i + 1); } - return; + return true; } if (!IsHex(ch) && ch != ':' && ch != '.') { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } } // Must contain a ']' - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } - private static void ValidateHostPort(string hostText, int offset) + private static bool IsHostPortValid(string hostText, int offset) { var firstChar = hostText[offset]; offset++; if (firstChar != ':' || offset == hostText.Length) { // Must have at least one number after the colon if present. - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } for (var i = offset; i < hostText.Length; i++) { if (!IsNumeric(hostText[i])) { - BadHttpRequestException.Throw(RequestRejectionReason.InvalidHostHeader, hostText); + return false; } } + + return true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/Kestrel.Core/Internal/Infrastructure/IKestrelTrace.cs b/src/Kestrel.Core/Internal/Infrastructure/IKestrelTrace.cs index caaf66cfae..d29b1b43d8 100644 --- a/src/Kestrel.Core/Internal/Infrastructure/IKestrelTrace.cs +++ b/src/Kestrel.Core/Internal/Infrastructure/IKestrelTrace.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; using Microsoft.Extensions.Logging; @@ -58,6 +59,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure void Http2StreamError(string connectionId, Http2StreamErrorException ex); + void Http2StreamResetAbort(string traceIdentifier, Http2ErrorCode error, ConnectionAbortedException abortReason); + void HPackDecodingError(string connectionId, int streamId, HPackDecodingException ex); } } diff --git a/src/Kestrel.Core/Internal/Infrastructure/KestrelTrace.cs b/src/Kestrel.Core/Internal/Infrastructure/KestrelTrace.cs index 3ef1aa1721..8f4a22539e 100644 --- a/src/Kestrel.Core/Internal/Infrastructure/KestrelTrace.cs +++ b/src/Kestrel.Core/Internal/Infrastructure/KestrelTrace.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; @@ -86,6 +87,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal private static readonly Action _applicationAbortedConnection = LoggerMessage.Define(LogLevel.Information, new EventId(34, nameof(RequestBodyDrainTimedOut)), @"Connection id ""{ConnectionId}"", Request id ""{TraceIdentifier}"": the application aborted the connection."); + private static readonly Action _http2StreamResetError = + LoggerMessage.Define(LogLevel.Debug, new EventId(35, nameof(Http2StreamResetAbort)), + @"Trace id ""{TraceIdentifier}"": HTTP/2 stream error ""{error}"". A Reset is being sent to the stream."); + protected readonly ILogger _logger; public KestrelTrace(ILogger logger) @@ -213,6 +218,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal _http2StreamError(_logger, connectionId, ex); } + public void Http2StreamResetAbort(string traceIdentifier, Http2ErrorCode error, ConnectionAbortedException abortReason) + { + _http2StreamResetError(_logger, traceIdentifier, error, abortReason); + } + public virtual void HPackDecodingError(string connectionId, int streamId, HPackDecodingException ex) { _hpackDecodingError(_logger, connectionId, streamId, ex); diff --git a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs index 20c07bf02b..60354b61ea 100644 --- a/src/Kestrel.Core/Properties/CoreStrings.Designer.cs +++ b/src/Kestrel.Core/Properties/CoreStrings.Designer.cs @@ -2002,6 +2002,62 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core internal static string FormatHttp2StreamAborted() => GetString("Http2StreamAborted"); + /// + /// CONNECT requests must not send :scheme or :path headers. + /// + internal static string Http2ErrorConnectMustNotSendSchemeOrPath + { + get => GetString("Http2ErrorConnectMustNotSendSchemeOrPath"); + } + + /// + /// CONNECT requests must not send :scheme or :path headers. + /// + internal static string FormatHttp2ErrorConnectMustNotSendSchemeOrPath() + => GetString("Http2ErrorConnectMustNotSendSchemeOrPath"); + + /// + /// The Method '{method}' is invalid. + /// + internal static string Http2ErrorMethodInvalid + { + get => GetString("Http2ErrorMethodInvalid"); + } + + /// + /// The Method '{method}' is invalid. + /// + internal static string FormatHttp2ErrorMethodInvalid(object method) + => string.Format(CultureInfo.CurrentCulture, GetString("Http2ErrorMethodInvalid", "method"), method); + + /// + /// The request :path is invalid: '{path}' + /// + internal static string Http2StreamErrorPathInvalid + { + get => GetString("Http2StreamErrorPathInvalid"); + } + + /// + /// The request :path is invalid: '{path}' + /// + internal static string FormatHttp2StreamErrorPathInvalid(object path) + => string.Format(CultureInfo.CurrentCulture, GetString("Http2StreamErrorPathInvalid", "path"), path); + + /// + /// The request :scheme header '{requestScheme}' does not match the transport scheme '{transportScheme}'. + /// + internal static string Http2StreamErrorSchemeMismatch + { + get => GetString("Http2StreamErrorSchemeMismatch"); + } + + /// + /// The request :scheme header '{requestScheme}' does not match the transport scheme '{transportScheme}'. + /// + internal static string FormatHttp2StreamErrorSchemeMismatch(object requestScheme, object transportScheme) + => string.Format(CultureInfo.CurrentCulture, GetString("Http2StreamErrorSchemeMismatch", "requestScheme", "transportScheme"), requestScheme, transportScheme); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs index 56ae2c8095..d584b8e5f9 100644 --- a/test/Kestrel.Core.Tests/Http2ConnectionTests.cs +++ b/test/Kestrel.Core.Tests/Http2ConnectionTests.cs @@ -116,7 +116,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests private readonly object _abortedStreamIdsLock = new object(); private readonly RequestDelegate _noopApplication; - private readonly RequestDelegate _echoHost; private readonly RequestDelegate _readHeadersApplication; private readonly RequestDelegate _readTrailersApplication; private readonly RequestDelegate _bufferingApplication; @@ -151,13 +150,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests _noopApplication = context => Task.CompletedTask; - _echoHost = context => - { - context.Response.Headers[HeaderNames.Host] = context.Request.Headers[HeaderNames.Host]; - - return Task.CompletedTask; - }; - _readHeadersApplication = context => { foreach (var header in context.Request.Headers) @@ -1495,311 +1487,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); } - [Fact] - public async Task HEADERS_Received_InvalidAuthority_400Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, "local=host:80"), - }; - await InitializeConnectionAsync(_noopApplication); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = 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); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(3, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("400", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders["content-length"]); - } - - [Fact] - public async Task HEADERS_Received_MissingAuthority_400Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - }; - await InitializeConnectionAsync(_noopApplication); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = 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); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(3, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("400", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders["content-length"]); - } - - [Fact] - public async Task HEADERS_Received_TwoHosts_400Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair("Host", "host1"), - new KeyValuePair("Host", "host2"), - }; - await InitializeConnectionAsync(_noopApplication); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = 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); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(3, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("400", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders["content-length"]); - } - - [Fact] - public async Task HEADERS_Received_EmptyAuthority_200Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, ""), - }; - await InitializeConnectionAsync(_noopApplication); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = 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); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(3, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders["content-length"]); - } - - [Fact] - public async Task HEADERS_Received_EmptyAuthorityOverridesHost_200Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, ""), - new KeyValuePair("Host", "abc"), - }; - await InitializeConnectionAsync(_echoHost); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 62, - withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, - withStreamId: 1); - await ExpectAsync(Http2FrameType.DATA, - withLength: 0, - withFlags: (byte)Http2DataFrameFlags.END_STREAM, - withStreamId: 1); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(4, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); - Assert.Equal("", _decodedHeaders[HeaderNames.Host]); - } - - [Fact] - public async Task HEADERS_Received_AuthorityOverridesHost_200Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, "def"), - new KeyValuePair("Host", "abc"), - }; - await InitializeConnectionAsync(_echoHost); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 65, - withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, - withStreamId: 1); - await ExpectAsync(Http2FrameType.DATA, - withLength: 0, - withFlags: (byte)Http2DataFrameFlags.END_STREAM, - withStreamId: 1); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(4, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); - Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); - } - - [Fact] - public async Task HEADERS_Received_MissingAuthorityFallsBackToHost_200Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair("Host", "abc"), - }; - await InitializeConnectionAsync(_echoHost); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 65, - withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, - withStreamId: 1); - await ExpectAsync(Http2FrameType.DATA, - withLength: 0, - withFlags: (byte)Http2DataFrameFlags.END_STREAM, - withStreamId: 1); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(4, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); - Assert.Equal("abc", _decodedHeaders[HeaderNames.Host]); - } - - [Fact] - public async Task HEADERS_Received_AuthorityOverridesInvalidHost_200Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, "def"), - new KeyValuePair("Host", "a=bc"), - }; - await InitializeConnectionAsync(_echoHost); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 65, - withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, - withStreamId: 1); - await ExpectAsync(Http2FrameType.DATA, - withLength: 0, - withFlags: (byte)Http2DataFrameFlags.END_STREAM, - withStreamId: 1); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(4, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); - Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); - } - - [Fact] - public async Task HEADERS_Received_InvalidAuthorityWithValidHost_400Status() - { - var headers = new[] - { - new KeyValuePair(HeaderNames.Method, "GET"), - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, "d=ef"), - new KeyValuePair("Host", "abc"), - }; - await InitializeConnectionAsync(_echoHost); - - await StartStreamAsync(1, headers, endStream: true); - - var headersFrame = 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); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - - _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); - - Assert.Equal(3, _decodedHeaders.Count); - Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); - Assert.Equal("400", _decodedHeaders[HeaderNames.Status]); - Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); - } - [Fact] public async Task PRIORITY_Received_StreamIdZero_ConnectionError() { @@ -1873,45 +1560,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests expectedErrorMessage: CoreStrings.FormatHttp2ErrorStreamSelfDependency(Http2FrameType.PRIORITY, 1)); } - [Fact] - public async Task RST_STREAM_Received_AbortsStream() - { - await InitializeConnectionAsync(_waitForAbortApplication); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: true); - await SendRstStreamAsync(1); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - - [Fact] - public async Task RST_STREAM_Received_AbortsStream_FlushedHeadersNotSent() - { - await InitializeConnectionAsync(_waitForAbortFlushingApplication); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: true); - await SendRstStreamAsync(1); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - - [Fact] - public async Task RST_STREAM_Received_AbortsStream_FlushedDataNotSent() - { - await InitializeConnectionAsync(_waitForAbortWithDataApplication); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: true); - await SendRstStreamAsync(1); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - [Fact] public async Task RST_STREAM_Received_RelievesConnectionBackpressure() { @@ -2116,92 +1764,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests Assert.Contains(5, _abortedStreamIds); } - [Fact] - public async Task RST_STREAM_WaitingForRequestBody_RequestBodyThrows() - { - var sem = new SemaphoreSlim(0); - await InitializeConnectionAsync(async context => - { - var streamIdFeature = context.Features.Get(); - - try - { - var readTask = context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); - sem.Release(); - await readTask; - - _runningStreams[streamIdFeature.StreamId].TrySetException(new Exception("ReadAsync was expected to throw.")); - } - catch (IOException) // Expected failure - { - await context.Response.Body.WriteAsync(new byte[10], 0, 10); - - lock (_abortedStreamIdsLock) - { - _abortedStreamIds.Add(streamIdFeature.StreamId); - } - - _runningStreams[streamIdFeature.StreamId].TrySetResult(null); - } - catch (Exception ex) - { - _runningStreams[streamIdFeature.StreamId].TrySetException(ex); - } - }); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: false); - await sem.WaitAsync().DefaultTimeout(); - await SendRstStreamAsync(1); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - - [Fact] - public async Task RST_STREAM_IncompleteRequest_RequestBodyThrows() - { - var sem = new SemaphoreSlim(0); - await InitializeConnectionAsync(async context => - { - var streamIdFeature = context.Features.Get(); - - try - { - var read = await context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); - var readTask = context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); - sem.Release(); - await readTask; - - _runningStreams[streamIdFeature.StreamId].TrySetException(new Exception("ReadAsync was expected to throw.")); - } - catch (IOException) // Expected failure - { - await context.Response.Body.WriteAsync(new byte[10], 0, 10); - - lock (_abortedStreamIdsLock) - { - _abortedStreamIds.Add(streamIdFeature.StreamId); - } - - _runningStreams[streamIdFeature.StreamId].TrySetResult(null); - } - catch (Exception ex) - { - _runningStreams[streamIdFeature.StreamId].TrySetException(ex); - } - }); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: false); - await SendDataAsync(1, new byte[10], endStream: false); - await sem.WaitAsync().DefaultTimeout(); - await SendRstStreamAsync(1); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - [Fact] public async Task RST_STREAM_Received_StreamIdZero_ConnectionError() { @@ -2278,98 +1840,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests expectedErrorMessage: CoreStrings.FormatHttp2ErrorHeadersInterleaved(Http2FrameType.RST_STREAM, streamId: 1, headersStreamId: 1)); } - [Fact] - public async Task RequestAbort_SendsRstStream() - { - await InitializeConnectionAsync(async context => - { - var streamIdFeature = context.Features.Get(); - - try - { - context.RequestAborted.Register(() => - { - lock (_abortedStreamIdsLock) - { - _abortedStreamIds.Add(streamIdFeature.StreamId); - } - - _runningStreams[streamIdFeature.StreamId].TrySetResult(null); - }); - - context.Abort(); - - // Not sent - await context.Response.Body.WriteAsync(new byte[10], 0, 10); - - await _runningStreams[streamIdFeature.StreamId].Task; - } - catch (Exception ex) - { - _runningStreams[streamIdFeature.StreamId].TrySetException(ex); - } - }); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: true); - await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.CANCEL, expectedErrorMessage: null); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - - [Fact] - public async Task RequestAbort_AfterDataSent_SendsRstStream() - { - await InitializeConnectionAsync(async context => - { - var streamIdFeature = context.Features.Get(); - - try - { - context.RequestAborted.Register(() => - { - lock (_abortedStreamIdsLock) - { - _abortedStreamIds.Add(streamIdFeature.StreamId); - } - - _runningStreams[streamIdFeature.StreamId].TrySetResult(null); - }); - - await context.Response.Body.WriteAsync(new byte[10], 0, 10); - - context.Abort(); - - // Not sent - await context.Response.Body.WriteAsync(new byte[11], 0, 11); - - await _runningStreams[streamIdFeature.StreamId].Task; - } - catch (Exception ex) - { - _runningStreams[streamIdFeature.StreamId].TrySetException(ex); - } - }); - - await StartStreamAsync(1, _browserRequestHeaders, endStream: true); - - var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, - withLength: 37, - withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, - withStreamId: 1); - await ExpectAsync(Http2FrameType.DATA, - withLength: 10, - withFlags: 0, - withStreamId: 1); - - await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.CANCEL, expectedErrorMessage: null); - await WaitForAllStreamsAsync(); - Assert.Contains(1, _abortedStreamIds); - - await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); - } - [Fact] public async Task SETTINGS_Received_Sends_ACK() { @@ -3540,28 +3010,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests await SendAsync(frame.Raw); } - private Task SendStreamDataAsync(int streamId, Span data) - { - var tasks = new List(); - var frame = new Http2Frame(); - - frame.PrepareData(streamId); - - while (data.Length > frame.Length) - { - data.Slice(0, frame.Length).CopyTo(frame.Payload); - data = data.Slice(frame.Length); - tasks.Add(SendAsync(frame.Raw)); - } - - frame.Length = data.Length; - frame.DataFlags = Http2DataFrameFlags.END_STREAM; - data.CopyTo(frame.Payload); - tasks.Add(SendAsync(frame.Raw)); - - return Task.WhenAll(tasks); - } - private Task WaitForAllStreamsAsync() { return Task.WhenAll(_runningStreams.Values.Select(tcs => tcs.Task)).DefaultTimeout(); @@ -3912,14 +3360,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests } } - private async Task ReceiveSettingsAck() - { - var frame = await ReceiveFrameAsync(); - - Assert.Equal(Http2FrameType.SETTINGS, frame.Type); - Assert.Equal(Http2SettingsFrameFlags.ACK, frame.SettingsFlags); - } - private async Task ExpectAsync(Http2FrameType type, int withLength, byte withFlags, int withStreamId) { var frame = await ReceiveFrameAsync(); @@ -4087,19 +3527,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests get { var data = new TheoryData>>(); - var methodHeader = new[] { new KeyValuePair(HeaderNames.Method, "CONNECT") }; - var requestHeaders = new[] - { - new KeyValuePair(HeaderNames.Path, "/"), - new KeyValuePair(HeaderNames.Scheme, "http"), - new KeyValuePair(HeaderNames.Authority, "127.0.0.1"), - }; - - foreach (var headerField in requestHeaders) - { - var headers = methodHeader.Concat(requestHeaders.Except(new[] { headerField })); - data.Add(headers); - } + var methodHeader = new KeyValuePair(HeaderNames.Method, "CONNECT"); + var headers = new[] { methodHeader }; + data.Add(headers); return data; } diff --git a/test/Kestrel.Core.Tests/Http2StreamTests.cs b/test/Kestrel.Core.Tests/Http2StreamTests.cs new file mode 100644 index 0000000000..da8e98b987 --- /dev/null +++ b/test/Kestrel.Core.Tests/Http2StreamTests.cs @@ -0,0 +1,1190 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2.HPack; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; +using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal; +using Microsoft.AspNetCore.Testing; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests +{ + public class Http2StreamTests : IDisposable, IHttpHeadersHandler + { + private static readonly string _largeHeaderValue = new string('a', HPackDecoder.MaxStringOctets); + + private static readonly IEnumerable> _browserRequestHeaders = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "localhost:80"), + new KeyValuePair("user-agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:54.0) Gecko/20100101 Firefox/54.0"), + new KeyValuePair("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"), + new KeyValuePair("accept-language", "en-US,en;q=0.5"), + new KeyValuePair("accept-encoding", "gzip, deflate, br"), + new KeyValuePair("upgrade-insecure-requests", "1"), + }; + + private readonly MemoryPool _memoryPool = KestrelMemoryPool.Create(); + private readonly DuplexPipe.DuplexPipePair _pair; + private readonly TestApplicationErrorLogger _logger; + private readonly Http2ConnectionContext _connectionContext; + private readonly Http2Connection _connection; + private readonly Http2PeerSettings _clientSettings = new Http2PeerSettings(); + private readonly HPackEncoder _hpackEncoder = new HPackEncoder(); + private readonly HPackDecoder _hpackDecoder; + + private readonly ConcurrentDictionary> _runningStreams = new ConcurrentDictionary>(); + private readonly Dictionary _decodedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _abortedStreamIds = new HashSet(); + private readonly object _abortedStreamIdsLock = new object(); + + private readonly RequestDelegate _noopApplication; + private readonly RequestDelegate _echoMethod; + private readonly RequestDelegate _echoHost; + private readonly RequestDelegate _echoPath; + private readonly RequestDelegate _waitForAbortApplication; + private readonly RequestDelegate _waitForAbortFlushingApplication; + private readonly RequestDelegate _waitForAbortWithDataApplication; + + private Task _connectionTask; + + public Http2StreamTests() + { + // Always dispatch test code back to the ThreadPool. This prevents deadlocks caused by continuing + // Http2Connection.ProcessRequestsAsync() loop with writer locks acquired. Run product code inline to make + // it easier to verify request frames are processed correctly immediately after sending the them. + var inputPipeOptions = new PipeOptions( + pool: _memoryPool, + readerScheduler: PipeScheduler.Inline, + writerScheduler: PipeScheduler.ThreadPool, + useSynchronizationContext: false + ); + var outputPipeOptions = new PipeOptions( + pool: _memoryPool, + readerScheduler: PipeScheduler.ThreadPool, + writerScheduler: PipeScheduler.Inline, + useSynchronizationContext: false + ); + + _pair = DuplexPipe.CreateConnectionPair(inputPipeOptions, outputPipeOptions); + + _noopApplication = context => Task.CompletedTask; + + _echoMethod = context => + { + context.Response.Headers["Method"] = context.Request.Method; + + return Task.CompletedTask; + }; + + _echoHost = context => + { + context.Response.Headers[HeaderNames.Host] = context.Request.Headers[HeaderNames.Host]; + + return Task.CompletedTask; + }; + + _echoPath = context => + { + context.Response.Headers["path"] = context.Request.Path.ToString(); + context.Response.Headers["rawtarget"] = context.Features.Get().RawTarget; + + return Task.CompletedTask; + }; + + _waitForAbortApplication = async context => + { + var streamIdFeature = context.Features.Get(); + var sem = new SemaphoreSlim(0); + + context.RequestAborted.Register(() => + { + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + sem.Release(); + }); + + await sem.WaitAsync().DefaultTimeout(); + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + }; + + _waitForAbortFlushingApplication = async context => + { + var streamIdFeature = context.Features.Get(); + var sem = new SemaphoreSlim(0); + + context.RequestAborted.Register(() => + { + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + sem.Release(); + }); + + await sem.WaitAsync().DefaultTimeout(); + + await context.Response.Body.FlushAsync(); + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + }; + + _waitForAbortWithDataApplication = async context => + { + var streamIdFeature = context.Features.Get(); + var sem = new SemaphoreSlim(0); + + context.RequestAborted.Register(() => + { + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + sem.Release(); + }); + + await sem.WaitAsync().DefaultTimeout(); + + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + }; + + _hpackDecoder = new HPackDecoder((int)_clientSettings.HeaderTableSize); + + _logger = new TestApplicationErrorLogger(); + + _connectionContext = new Http2ConnectionContext + { + ConnectionFeatures = new FeatureCollection(), + ServiceContext = new TestServiceContext() + { + Log = new TestKestrelTrace(_logger) + }, + MemoryPool = _memoryPool, + Application = _pair.Application, + Transport = _pair.Transport + }; + + _connection = new Http2Connection(_connectionContext); + } + + public void Dispose() + { + _pair.Application.Input.Complete(); + _pair.Application.Output.Complete(); + _pair.Transport.Input.Complete(); + _pair.Transport.Output.Complete(); + _memoryPool.Dispose(); + } + + void IHttpHeadersHandler.OnHeader(Span name, Span value) + { + _decodedHeaders[name.GetAsciiStringNonNullCharacters()] = value.GetAsciiStringNonNullCharacters(); + } + + [Fact] + public async Task HEADERS_Received_EmptyMethod_Reset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, ""), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "localhost:80"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, CoreStrings.FormatHttp2ErrorMethodInvalid("")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_InvlaidCustomMethod_Reset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "Hello,World"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "localhost:80"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, CoreStrings.FormatHttp2ErrorMethodInvalid("Hello,World")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_CustomMethod_Accepted() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "Custom"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "localhost:80"), + }; + await InitializeConnectionAsync(_echoMethod); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 70, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("Custom", _decodedHeaders["Method"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_CONNECTMethod_Accepted() + { + await InitializeConnectionAsync(_echoMethod); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "CONNECT") }; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 71, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2HeadersFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("CONNECT", _decodedHeaders["Method"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_OPTIONSStar_LeftOutOfPath() + { + await InitializeConnectionAsync(_echoPath); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "OPTIONS"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Path, "*")}; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 75, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2HeadersFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(5, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("", _decodedHeaders["path"]); + Assert.Equal("*", _decodedHeaders["rawtarget"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_OPTIONSSlash_Accepted() + { + await InitializeConnectionAsync(_echoPath); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "OPTIONS"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Path, "/")}; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 76, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2HeadersFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(5, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("/", _decodedHeaders["path"]); + Assert.Equal("/", _decodedHeaders["rawtarget"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_PathAndQuery_Seperated() + { + await InitializeConnectionAsync(context => + { + context.Response.Headers["path"] = context.Request.Path.Value; + context.Response.Headers["query"] = context.Request.QueryString.Value; + context.Response.Headers["rawtarget"] = context.Features.Get().RawTarget; + return Task.CompletedTask; + }); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Path, "/a/path?a&que%35ry")}; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 118, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2HeadersFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(6, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("/a/path", _decodedHeaders["path"]); + Assert.Equal("?a&que%35ry", _decodedHeaders["query"]); + Assert.Equal("/a/path?a&que%35ry", _decodedHeaders["rawtarget"]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Theory] + [InlineData("/","/")] + [InlineData("/a%5E", "/a^")] + [InlineData("/a%E2%82%AC", "/a€")] + [InlineData("/a%2Fb", "/a%2Fb")] // Forward slash, not decoded + [InlineData("/a%b", "/a%b")] // Incomplete encoding, not decoded + [InlineData("/a/b/c/../d", "/a/b/d")] // Navigation processed + [InlineData("/a/b/c/../../../../d", "/d")] // Navigation escape prevented + [InlineData("/a/b/c/.%2E/d", "/a/b/d")] // Decode before navigation processing + public async Task HEADERS_Received_Path_DecodedAndNormalized(string input, string expected) + { + await InitializeConnectionAsync(context => + { + Assert.Equal(expected, context.Request.Path.Value); + Assert.Equal(input, context.Features.Get().RawTarget); + return Task.CompletedTask; + }); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Path, input)}; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 55, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2HeadersFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Theory] + [InlineData(HeaderNames.Path, "/")] + [InlineData(HeaderNames.Scheme, "http")] + public async Task HEADERS_Received_CONNECTMethod_WithSchemeOrPath_Reset(string headerName, string value) + { + await InitializeConnectionAsync(_noopApplication); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "CONNECT"), + new KeyValuePair(headerName, value) }; + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, CoreStrings.Http2ErrorConnectMustNotSendSchemeOrPath); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_SchemeMismatch_Reset() + { + await InitializeConnectionAsync(_noopApplication); + + // :path and :scheme are not allowed, :authority is optional + var headers = new[] { new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "https") }; // Not the expected "http" + await SendHeadersAsync(1, Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM, headers); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, + CoreStrings.FormatHttp2StreamErrorSchemeMismatch("https", "http")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_MissingAuthority_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = 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); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_EmptyAuthority_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, ""), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = 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); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(3, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders["content-length"]); + } + + [Fact] + public async Task HEADERS_Received_MissingAuthorityFallsBackToHost_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("abc", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_EmptyAuthorityIgnoredOverHost_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, ""), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("abc", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_AuthorityOverridesHost_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "def"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_AuthorityOverridesInvalidHost_200Status() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "def"), + new KeyValuePair("Host", "a=bc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 65, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 0, + withFlags: (byte)Http2DataFrameFlags.END_STREAM, + withStreamId: 1); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + + _hpackDecoder.Decode(headersFrame.HeadersPayload, endHeaders: false, handler: this); + + Assert.Equal(4, _decodedHeaders.Count); + Assert.Contains("date", _decodedHeaders.Keys, StringComparer.OrdinalIgnoreCase); + Assert.Equal("200", _decodedHeaders[HeaderNames.Status]); + Assert.Equal("0", _decodedHeaders[HeaderNames.ContentLength]); + Assert.Equal("def", _decodedHeaders[HeaderNames.Host]); + } + + [Fact] + public async Task HEADERS_Received_InvalidAuthority_Reset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "local=host:80"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, + CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("local=host:80")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_InvalidAuthorityWithValidHost_Reset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair(HeaderNames.Authority, "d=ef"), + new KeyValuePair("Host", "abc"), + }; + await InitializeConnectionAsync(_echoHost); + + await StartStreamAsync(1, headers, endStream: true); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, + CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("d=ef")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task HEADERS_Received_TwoHosts_StreamReset() + { + var headers = new[] + { + new KeyValuePair(HeaderNames.Method, "GET"), + new KeyValuePair(HeaderNames.Path, "/"), + new KeyValuePair(HeaderNames.Scheme, "http"), + new KeyValuePair("Host", "host1"), + new KeyValuePair("Host", "host2"), + }; + await InitializeConnectionAsync(_noopApplication); + + await StartStreamAsync(1, headers, endStream: true); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.PROTOCOL_ERROR, + CoreStrings.FormatBadRequest_InvalidHostHeader_Detail("host1,host2")); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RST_STREAM_Received_AbortsStream() + { + await InitializeConnectionAsync(_waitForAbortApplication); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true); + await SendRstStreamAsync(1); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RST_STREAM_Received_AbortsStream_FlushedHeadersNotSent() + { + await InitializeConnectionAsync(_waitForAbortFlushingApplication); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true); + await SendRstStreamAsync(1); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RST_STREAM_Received_AbortsStream_FlushedDataNotSent() + { + await InitializeConnectionAsync(_waitForAbortWithDataApplication); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true); + await SendRstStreamAsync(1); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RST_STREAM_WaitingForRequestBody_RequestBodyThrows() + { + var sem = new SemaphoreSlim(0); + await InitializeConnectionAsync(async context => + { + var streamIdFeature = context.Features.Get(); + + try + { + var readTask = context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); + sem.Release(); + await readTask; + + _runningStreams[streamIdFeature.StreamId].TrySetException(new Exception("ReadAsync was expected to throw.")); + } + catch (IOException) // Expected failure + { + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + } + catch (Exception ex) + { + _runningStreams[streamIdFeature.StreamId].TrySetException(ex); + } + }); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: false); + await sem.WaitAsync().DefaultTimeout(); + await SendRstStreamAsync(1); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RST_STREAM_IncompleteRequest_RequestBodyThrows() + { + var sem = new SemaphoreSlim(0); + await InitializeConnectionAsync(async context => + { + var streamIdFeature = context.Features.Get(); + + try + { + var read = await context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); + var readTask = context.Request.Body.ReadAsync(new byte[100], 0, 100).DefaultTimeout(); + sem.Release(); + await readTask; + + _runningStreams[streamIdFeature.StreamId].TrySetException(new Exception("ReadAsync was expected to throw.")); + } + catch (IOException) // Expected failure + { + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + } + catch (Exception ex) + { + _runningStreams[streamIdFeature.StreamId].TrySetException(ex); + } + }); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: false); + await SendDataAsync(1, new byte[10], endStream: false); + await sem.WaitAsync().DefaultTimeout(); + await SendRstStreamAsync(1); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RequestAbort_SendsRstStream() + { + await InitializeConnectionAsync(async context => + { + var streamIdFeature = context.Features.Get(); + + try + { + context.RequestAborted.Register(() => + { + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + }); + + context.Abort(); + + // Not sent + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + + await _runningStreams[streamIdFeature.StreamId].Task; + } + catch (Exception ex) + { + _runningStreams[streamIdFeature.StreamId].TrySetException(ex); + } + }); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true); + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.CANCEL, CoreStrings.ConnectionAbortedByApplication); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + [Fact] + public async Task RequestAbort_AfterDataSent_SendsRstStream() + { + await InitializeConnectionAsync(async context => + { + var streamIdFeature = context.Features.Get(); + + try + { + context.RequestAborted.Register(() => + { + lock (_abortedStreamIdsLock) + { + _abortedStreamIds.Add(streamIdFeature.StreamId); + } + + _runningStreams[streamIdFeature.StreamId].TrySetResult(null); + }); + + await context.Response.Body.WriteAsync(new byte[10], 0, 10); + + context.Abort(); + + // Not sent + await context.Response.Body.WriteAsync(new byte[11], 0, 11); + + await _runningStreams[streamIdFeature.StreamId].Task; + } + catch (Exception ex) + { + _runningStreams[streamIdFeature.StreamId].TrySetException(ex); + } + }); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true); + + var headersFrame = await ExpectAsync(Http2FrameType.HEADERS, + withLength: 37, + withFlags: (byte)Http2HeadersFrameFlags.END_HEADERS, + withStreamId: 1); + await ExpectAsync(Http2FrameType.DATA, + withLength: 10, + withFlags: 0, + withStreamId: 1); + + await WaitForStreamErrorAsync(expectedStreamId: 1, Http2ErrorCode.CANCEL, CoreStrings.ConnectionAbortedByApplication); + await WaitForAllStreamsAsync(); + Assert.Contains(1, _abortedStreamIds); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false); + } + + private async Task InitializeConnectionAsync(RequestDelegate application) + { + _connectionTask = _connection.ProcessRequestsAsync(new DummyApplication(application)); + + await SendPreambleAsync().ConfigureAwait(false); + await SendSettingsAsync(); + + await ExpectAsync(Http2FrameType.SETTINGS, + withLength: 0, + withFlags: 0, + withStreamId: 0); + + await ExpectAsync(Http2FrameType.SETTINGS, + withLength: 0, + withFlags: (byte)Http2SettingsFrameFlags.ACK, + withStreamId: 0); + } + + private async Task StartStreamAsync(int streamId, IEnumerable> headers, bool endStream) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _runningStreams[streamId] = tcs; + + var frame = new Http2Frame(); + frame.PrepareHeaders(Http2HeadersFrameFlags.NONE, streamId); + var done = _hpackEncoder.BeginEncode(headers, frame.HeadersPayload, out var length); + frame.Length = length; + + if (done) + { + frame.HeadersFlags = Http2HeadersFrameFlags.END_HEADERS; + } + + if (endStream) + { + frame.HeadersFlags |= Http2HeadersFrameFlags.END_STREAM; + } + + await SendAsync(frame.Raw); + + while (!done) + { + frame.PrepareContinuation(Http2ContinuationFrameFlags.NONE, streamId); + done = _hpackEncoder.Encode(frame.HeadersPayload, out length); + frame.Length = length; + + if (done) + { + frame.ContinuationFlags = Http2ContinuationFrameFlags.END_HEADERS; + } + + await SendAsync(frame.Raw); + } + } + + private Task WaitForAllStreamsAsync() + { + return Task.WhenAll(_runningStreams.Values.Select(tcs => tcs.Task)).DefaultTimeout(); + } + + private Task SendAsync(ReadOnlySpan span) + { + var writableBuffer = _pair.Application.Output; + writableBuffer.Write(span); + return FlushAsync(writableBuffer); + } + + private static async Task FlushAsync(PipeWriter writableBuffer) + { + await writableBuffer.FlushAsync(); + } + + private Task SendPreambleAsync() => SendAsync(new ArraySegment(Http2Connection.ClientPreface)); + + private Task SendSettingsAsync() + { + var frame = new Http2Frame(); + frame.PrepareSettings(Http2SettingsFrameFlags.NONE, _clientSettings); + return SendAsync(frame.Raw); + } + + private async Task SendHeadersAsync(int streamId, Http2HeadersFrameFlags flags, IEnumerable> headers) + { + var frame = new Http2Frame(); + + frame.PrepareHeaders(flags, streamId); + var done = _hpackEncoder.BeginEncode(headers, frame.Payload, out var length); + frame.Length = length; + + await SendAsync(frame.Raw); + + return done; + } + + private Task SendDataAsync(int streamId, Span data, bool endStream) + { + var frame = new Http2Frame(); + + frame.PrepareData(streamId); + frame.Length = data.Length; + frame.DataFlags = endStream ? Http2DataFrameFlags.END_STREAM : Http2DataFrameFlags.NONE; + data.CopyTo(frame.DataPayload); + + return SendAsync(frame.Raw); + } + + private Task SendRstStreamAsync(int streamId) + { + var rstStreamFrame = new Http2Frame(); + rstStreamFrame.PrepareRstStream(streamId, Http2ErrorCode.CANCEL); + return SendAsync(rstStreamFrame.Raw); + } + + private async Task ReceiveFrameAsync() + { + var frame = new Http2Frame(); + + while (true) + { + var result = await _pair.Application.Input.ReadAsync(); + var buffer = result.Buffer; + var consumed = buffer.Start; + var examined = buffer.End; + + try + { + Assert.True(buffer.Length > 0); + + if (Http2FrameReader.ReadFrame(buffer, frame, 16_384, out consumed, out examined)) + { + return frame; + } + + if (result.IsCompleted) + { + throw new IOException("The reader completed without returning a frame."); + } + } + finally + { + _pair.Application.Input.AdvanceTo(consumed, examined); + } + } + } + + private async Task ExpectAsync(Http2FrameType type, int withLength, byte withFlags, int withStreamId) + { + var frame = await ReceiveFrameAsync(); + + Assert.Equal(type, frame.Type); + Assert.Equal(withLength, frame.Length); + Assert.Equal(withFlags, frame.Flags); + Assert.Equal(withStreamId, frame.StreamId); + + return frame; + } + + private Task StopConnectionAsync(int expectedLastStreamId, bool ignoreNonGoAwayFrames) + { + _pair.Application.Output.Complete(); + + return WaitForConnectionStopAsync(expectedLastStreamId, ignoreNonGoAwayFrames); + } + + private Task WaitForConnectionStopAsync(int expectedLastStreamId, bool ignoreNonGoAwayFrames) + { + return WaitForConnectionErrorAsync(ignoreNonGoAwayFrames, expectedLastStreamId, Http2ErrorCode.NO_ERROR, expectedErrorMessage: null); + } + + private async Task WaitForConnectionErrorAsync(bool ignoreNonGoAwayFrames, int expectedLastStreamId, Http2ErrorCode expectedErrorCode, string expectedErrorMessage) + where TException : Exception + { + var frame = await ReceiveFrameAsync(); + + if (ignoreNonGoAwayFrames) + { + while (frame.Type != Http2FrameType.GOAWAY) + { + frame = await ReceiveFrameAsync(); + } + } + + Assert.Equal(Http2FrameType.GOAWAY, frame.Type); + Assert.Equal(8, frame.Length); + Assert.Equal(0, frame.Flags); + Assert.Equal(0, frame.StreamId); + Assert.Equal(expectedLastStreamId, frame.GoAwayLastStreamId); + Assert.Equal(expectedErrorCode, frame.GoAwayErrorCode); + + if (expectedErrorMessage != null) + { + var message = Assert.Single(_logger.Messages, m => m.Exception is TException); + Assert.Contains(expectedErrorMessage, message.Exception.Message); + } + + await _connectionTask; + _pair.Application.Output.Complete(); + } + + private async Task WaitForStreamErrorAsync(int expectedStreamId, Http2ErrorCode expectedErrorCode, string expectedErrorMessage) + { + var frame = await ReceiveFrameAsync(); + + Assert.Equal(Http2FrameType.RST_STREAM, frame.Type); + Assert.Equal(4, frame.Length); + Assert.Equal(0, frame.Flags); + Assert.Equal(expectedStreamId, frame.StreamId); + Assert.Equal(expectedErrorCode, frame.RstStreamErrorCode); + + if (expectedErrorMessage != null) + { + var message = Assert.Single(_logger.Messages, m => m.Exception is ConnectionAbortedException); + Assert.Contains(expectedErrorMessage, message.Exception.Message); + } + } + } +} \ No newline at end of file diff --git a/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs b/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs index b503eab04e..7f701696bd 100644 --- a/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs +++ b/test/Kestrel.Core.Tests/HttpUtilitiesTest.cs @@ -170,8 +170,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests [MemberData(nameof(HostHeaderData))] public void ValidHostHeadersParsed(string host) { - HttpUtilities.ValidateHostHeader(host); - // Shouldn't throw + Assert.True(HttpUtilities.IsHostHeaderValid(host)); } public static TheoryData HostHeaderInvalidData @@ -225,7 +224,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests [MemberData(nameof(HostHeaderInvalidData))] public void InvalidHostHeadersRejected(string host) { - Assert.Throws(() => HttpUtilities.ValidateHostHeader(host)); + Assert.False(HttpUtilities.IsHostHeaderValid(host)); } } } \ No newline at end of file diff --git a/test/Kestrel.FunctionalTests/Http2/H2SpecTests.cs b/test/Kestrel.FunctionalTests/Http2/H2SpecTests.cs index 9e2db9ba26..7e0c85e55c 100644 --- a/test/Kestrel.FunctionalTests/Http2/H2SpecTests.cs +++ b/test/Kestrel.FunctionalTests/Http2/H2SpecTests.cs @@ -54,7 +54,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests.Http2 get { var dataset = new TheoryData(); - var toSkip = new[] { "hpack/4.2/1", "http2/5.1/8", "http2/8.1.2.3/1", "http2/8.1.2.6/1", "http2/8.1.2.6/2" }; + var toSkip = new[] { "hpack/4.2/1", "http2/5.1/8", "http2/8.1.2.6/1", "http2/8.1.2.6/2" }; foreach (var testcase in H2SpecCommands.EnumerateTestCases()) { diff --git a/test/shared/CompositeKestrelTrace.cs b/test/shared/CompositeKestrelTrace.cs index 33110c85d2..4e3a1ca13d 100644 --- a/test/shared/CompositeKestrelTrace.cs +++ b/test/shared/CompositeKestrelTrace.cs @@ -3,6 +3,7 @@ using System; using System.IO.Pipelines; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; @@ -189,5 +190,11 @@ namespace Microsoft.AspNetCore.Testing _trace1.HPackDecodingError(connectionId, streamId, ex); _trace2.HPackDecodingError(connectionId, streamId, ex); } + + public void Http2StreamResetAbort(string traceIdentifier, Http2ErrorCode error, ConnectionAbortedException abortReason) + { + _trace1.Http2StreamResetAbort(traceIdentifier, error, abortReason); + _trace2.Http2StreamResetAbort(traceIdentifier, error, abortReason); + } } }