Handle absolute, asterisk, and authority-form request targets
Improves compliance with RFC 7230 on the expected handling of requests that have URI or asterisk in the request target. This means rejecting asterisk requests that are not OPTIONS and rejecting authority-form requests taht are not CONNECT. This also means the server will handle the path and query on targets with absolute URIs as request-targets.
This commit is contained in:
parent
941d396942
commit
49b328d4c2
|
|
@ -4,19 +4,32 @@
|
|||
using System.IO;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Http;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace Microsoft.AspNetCore.Server.Kestrel
|
||||
{
|
||||
public sealed class BadHttpRequestException : IOException
|
||||
{
|
||||
private BadHttpRequestException(string message, int statusCode)
|
||||
: this(message, statusCode, null)
|
||||
{ }
|
||||
|
||||
private BadHttpRequestException(string message, int statusCode, HttpMethod? requiredMethod)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
|
||||
if (requiredMethod.HasValue)
|
||||
{
|
||||
AllowedHeader = HttpUtilities.MethodToString(requiredMethod.Value);
|
||||
}
|
||||
}
|
||||
|
||||
internal int StatusCode { get; }
|
||||
|
||||
internal StringValues AllowedHeader { get; }
|
||||
|
||||
internal static BadHttpRequestException GetException(RequestRejectionReason reason)
|
||||
{
|
||||
BadHttpRequestException ex;
|
||||
|
|
@ -61,6 +74,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel
|
|||
case RequestRejectionReason.RequestTimeout:
|
||||
ex = new BadHttpRequestException("Request timed out.", StatusCodes.Status408RequestTimeout);
|
||||
break;
|
||||
case RequestRejectionReason.OptionsMethodRequired:
|
||||
ex = new BadHttpRequestException("Method not allowed.", StatusCodes.Status405MethodNotAllowed, HttpMethod.Options);
|
||||
break;
|
||||
case RequestRejectionReason.ConnectMethodRequired:
|
||||
ex = new BadHttpRequestException("Method not allowed.", StatusCodes.Status405MethodNotAllowed, HttpMethod.Connect);
|
||||
break;
|
||||
default:
|
||||
ex = new BadHttpRequestException("Bad request.", StatusCodes.Status400BadRequest);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ using System.Net;
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web.Utf8;
|
||||
using System.Text.Utf8;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
|
@ -27,6 +26,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
{
|
||||
public abstract partial class Frame : IFrameControl, IHttpRequestLineHandler, IHttpHeadersHandler
|
||||
{
|
||||
private const byte ByteAsterisk = (byte)'*';
|
||||
private const byte ByteForwardSlash = (byte)'/';
|
||||
private const byte BytePercentage = (byte)'%';
|
||||
|
||||
private static readonly ArraySegment<byte> _endChunkedResponseBytes = CreateAsciiByteArraySegment("0\r\n\r\n");
|
||||
|
|
@ -39,6 +40,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
private static readonly byte[] _bytesEndHeaders = Encoding.ASCII.GetBytes("\r\n\r\n");
|
||||
private static readonly byte[] _bytesServer = Encoding.ASCII.GetBytes("\r\nServer: Kestrel");
|
||||
|
||||
private const string EmptyPath = "/";
|
||||
private const string Asterisk = "*";
|
||||
|
||||
private readonly object _onStartingSync = new Object();
|
||||
private readonly object _onCompletedSync = new Object();
|
||||
|
||||
|
|
@ -818,7 +822,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
// that should take precedence.
|
||||
if (_requestRejectedException != null)
|
||||
{
|
||||
SetErrorResponseHeaders(statusCode: _requestRejectedException.StatusCode);
|
||||
SetErrorResponseException(_requestRejectedException);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -1077,7 +1081,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
{
|
||||
buffer = buffer.Slice(buffer.Start, _remainingRequestHeadersBytesAllowed);
|
||||
|
||||
// If we sliced it means the current buffer bigger than what we're
|
||||
// If we sliced it means the current buffer bigger than what we're
|
||||
// allowed to look at
|
||||
overLength = true;
|
||||
}
|
||||
|
|
@ -1127,6 +1131,16 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
}
|
||||
}
|
||||
|
||||
private void SetErrorResponseException(BadHttpRequestException ex)
|
||||
{
|
||||
SetErrorResponseHeaders(ex.StatusCode);
|
||||
|
||||
if (!StringValues.IsNullOrEmpty(ex.AllowedHeader))
|
||||
{
|
||||
FrameResponseHeaders.HeaderAllow = ex.AllowedHeader;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetErrorResponseHeaders(int statusCode)
|
||||
{
|
||||
Debug.Assert(!HasResponseStarted, $"{nameof(SetErrorResponseHeaders)} called after response had already started.");
|
||||
|
|
@ -1179,6 +1193,15 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
throw BadHttpRequestException.GetException(reason, value);
|
||||
}
|
||||
|
||||
private void RejectRequestLine(Span<byte> requestLine)
|
||||
{
|
||||
Debug.Assert(Log.IsEnabled(LogLevel.Information) == true, "Use RejectRequest instead to improve inlining when log is disabled");
|
||||
|
||||
const int MaxRequestLineError = 32;
|
||||
var line = requestLine.GetAsciiStringEscaped(MaxRequestLineError);
|
||||
throw BadHttpRequestException.GetException(RequestRejectionReason.InvalidRequestLine, line);
|
||||
}
|
||||
|
||||
public void SetBadRequestState(RequestRejectionReason reason)
|
||||
{
|
||||
SetBadRequestState(BadHttpRequestException.GetException(reason));
|
||||
|
|
@ -1190,7 +1213,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
|
||||
if (!HasResponseStarted)
|
||||
{
|
||||
SetErrorResponseHeaders(ex.StatusCode);
|
||||
SetErrorResponseException(ex);
|
||||
}
|
||||
|
||||
_keepAlive = false;
|
||||
|
|
@ -1216,8 +1239,51 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
Log.ApplicationError(ConnectionId, ex);
|
||||
}
|
||||
|
||||
public void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, bool pathEncoded)
|
||||
public void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, Span<byte> line, bool pathEncoded)
|
||||
{
|
||||
Debug.Assert(target.Length != 0, "Request target must be non-zero length");
|
||||
|
||||
var ch = target[0];
|
||||
if (ch == ByteForwardSlash)
|
||||
{
|
||||
// origin-form.
|
||||
// The most common form of request-target.
|
||||
// https://tools.ietf.org/html/rfc7230#section-5.3.1
|
||||
OnOriginFormTarget(method, version, target, path, query, customMethod, pathEncoded);
|
||||
}
|
||||
else if (ch == ByteAsterisk && target.Length == 1)
|
||||
{
|
||||
OnAsteriskFormTarget(method);
|
||||
}
|
||||
else if (target.GetKnownHttpScheme(out var scheme))
|
||||
{
|
||||
OnAbsoluteFormTarget(target, query, line);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Assume anything else is considered authority form.
|
||||
// FYI: this should be an edge case. This should only happen when
|
||||
// a client mistakenly things this server is a proxy server.
|
||||
|
||||
OnAuthorityFormTarget(method, target, line);
|
||||
}
|
||||
|
||||
Method = method != HttpMethod.Custom
|
||||
? HttpUtilities.MethodToString(method) ?? string.Empty
|
||||
: customMethod.GetAsciiStringNonNullCharacters();
|
||||
HttpVersion = HttpUtilities.VersionToString(version);
|
||||
|
||||
Debug.Assert(RawTarget != null, "RawTarget was not set");
|
||||
Debug.Assert(Method != null, "Method was not set");
|
||||
Debug.Assert(Path != null, "Path was not set");
|
||||
Debug.Assert(QueryString != "QueryString was not set");
|
||||
Debug.Assert(HttpVersion != "HttpVersion was not set");
|
||||
}
|
||||
|
||||
private void OnOriginFormTarget(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, bool pathEncoded)
|
||||
{
|
||||
Debug.Assert(target[0] == ByteForwardSlash, "Should only be called when path starts with /");
|
||||
|
||||
// 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"
|
||||
|
|
@ -1249,34 +1315,111 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
}
|
||||
}
|
||||
|
||||
var normalizedTarget = PathNormalizer.RemoveDotSegments(requestUrlPath);
|
||||
if (method != HttpMethod.Custom)
|
||||
{
|
||||
Method = HttpUtilities.MethodToString(method) ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
Method = customMethod.GetAsciiStringNonNullCharacters();
|
||||
}
|
||||
|
||||
QueryString = query.GetAsciiStringNonNullCharacters();
|
||||
RawTarget = rawTarget;
|
||||
HttpVersion = HttpUtilities.VersionToString(version);
|
||||
SetNormalizedPath(requestUrlPath);
|
||||
}
|
||||
|
||||
private void OnAuthorityFormTarget(HttpMethod method, Span<byte> target, Span<byte> line)
|
||||
{
|
||||
// TODO Validate that target is a correct host[:port] string.
|
||||
// Reject as 400 if not. This is just a quick scan for invalid characters
|
||||
// but doesn't check that the target fully matches the URI spec.
|
||||
for (var i = 0; i < target.Length; i++)
|
||||
{
|
||||
var ch = target[i];
|
||||
if (!UriUtilities.IsValidAuthorityCharacter(ch))
|
||||
{
|
||||
if (Log.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
RejectRequestLine(line);
|
||||
}
|
||||
|
||||
throw BadHttpRequestException.GetException(RequestRejectionReason.InvalidRequestLine);
|
||||
}
|
||||
}
|
||||
|
||||
// The authority-form of request-target is only used for CONNECT
|
||||
// requests (https://tools.ietf.org/html/rfc7231#section-4.3.6).
|
||||
if (method != HttpMethod.Connect)
|
||||
{
|
||||
RejectRequest(RequestRejectionReason.ConnectMethodRequired);
|
||||
}
|
||||
|
||||
// When making a CONNECT request to establish a tunnel through one or
|
||||
// more proxies, a client MUST send only the target URI's authority
|
||||
// component(excluding any userinfo and its "@" delimiter) as the
|
||||
// request - target.For example,
|
||||
//
|
||||
// CONNECT www.example.com:80 HTTP/1.1
|
||||
//
|
||||
// Allowed characters in the 'host + port' section of authority.
|
||||
// See https://tools.ietf.org/html/rfc3986#section-3.2
|
||||
|
||||
RawTarget = target.GetAsciiStringNonNullCharacters();
|
||||
Path = string.Empty;
|
||||
PathBase = string.Empty;
|
||||
QueryString = string.Empty;
|
||||
}
|
||||
|
||||
private void OnAsteriskFormTarget(HttpMethod method)
|
||||
{
|
||||
// The asterisk-form of request-target is only used for a server-wide
|
||||
// OPTIONS request (https://tools.ietf.org/html/rfc7231#section-4.3.7).
|
||||
if (method != HttpMethod.Options)
|
||||
{
|
||||
RejectRequest(RequestRejectionReason.OptionsMethodRequired);
|
||||
}
|
||||
|
||||
RawTarget = Asterisk;
|
||||
Path = string.Empty;
|
||||
PathBase = string.Empty;
|
||||
QueryString = string.Empty;
|
||||
}
|
||||
|
||||
private void OnAbsoluteFormTarget(Span<byte> target, Span<byte> query, Span<byte> line)
|
||||
{
|
||||
// absolute-form
|
||||
// https://tools.ietf.org/html/rfc7230#section-5.3.2
|
||||
|
||||
// This code should be the edge-case.
|
||||
|
||||
// From the spec:
|
||||
// a server MUST accept the absolute-form in requests, even though
|
||||
// HTTP/1.1 clients will only send them in requests to proxies.
|
||||
|
||||
RawTarget = target.GetAsciiStringNonNullCharacters();
|
||||
|
||||
// Validation of absolute URIs is slow, but clients
|
||||
// should not be sending this form anyways, so perf optimization
|
||||
// not high priority
|
||||
|
||||
if (!Uri.TryCreate(RawTarget, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (Log.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
RejectRequestLine(line);
|
||||
}
|
||||
|
||||
throw BadHttpRequestException.GetException(RequestRejectionReason.InvalidRequestLine);
|
||||
}
|
||||
|
||||
SetNormalizedPath(uri.LocalPath);
|
||||
// don't use uri.Query because we need the unescaped version
|
||||
QueryString = query.GetAsciiStringNonNullCharacters();
|
||||
}
|
||||
|
||||
private void SetNormalizedPath(string requestPath)
|
||||
{
|
||||
var normalizedTarget = PathNormalizer.RemoveDotSegments(requestPath);
|
||||
if (RequestUrlStartsWithPathBase(normalizedTarget, out bool caseMatches))
|
||||
{
|
||||
PathBase = caseMatches ? _pathBase : normalizedTarget.Substring(0, _pathBase.Length);
|
||||
Path = normalizedTarget.Substring(_pathBase.Length);
|
||||
}
|
||||
else if (rawTarget[0] == '/') // check rawTarget since normalizedTarget can be "" or "/" after dot segment removal
|
||||
{
|
||||
Path = normalizedTarget;
|
||||
}
|
||||
else
|
||||
{
|
||||
Path = string.Empty;
|
||||
PathBase = string.Empty;
|
||||
QueryString = string.Empty;
|
||||
Path = normalizedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
// 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.
|
||||
|
||||
namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
||||
{
|
||||
public enum HttpScheme
|
||||
{
|
||||
Unknown = -1,
|
||||
Http = 0,
|
||||
Https = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
{
|
||||
public interface IHttpRequestLineHandler
|
||||
{
|
||||
void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, bool pathEncoded);
|
||||
void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, Span<byte> line, bool pathEncoded);
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,9 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
RejectRequestLine(data, length);
|
||||
}
|
||||
|
||||
handler.OnStartLine(method, httpVersion, targetBuffer, pathBuffer, query, customMethod, pathEncoded);
|
||||
var line = new Span<byte>(data, length);
|
||||
|
||||
handler.OnStartLine(method, httpVersion, targetBuffer, pathBuffer, query, customMethod, line, pathEncoded);
|
||||
}
|
||||
|
||||
public unsafe bool ParseHeaders<T>(T handler, ReadableBuffer buffer, out ReadCursor consumed, out ReadCursor examined, out int consumedBytes) where T : IHttpHeadersHandler
|
||||
|
|
@ -341,7 +343,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
// Skip colon from value start
|
||||
var valueStart = nameEnd + 1;
|
||||
// Ignore start whitespace
|
||||
for(; valueStart < valueEnd; valueStart++)
|
||||
for (; valueStart < valueEnd; valueStart++)
|
||||
{
|
||||
var ch = headerLine[valueStart];
|
||||
if (ch != ByteTab && ch != ByteSpace && ch != ByteCR)
|
||||
|
|
@ -409,7 +411,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
}
|
||||
}
|
||||
return false;
|
||||
found:
|
||||
found:
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
|
|||
RequestTimeout,
|
||||
FinalTransferCodingNotChunked,
|
||||
LengthRequired,
|
||||
LengthRequiredHttp10
|
||||
LengthRequiredHttp10,
|
||||
OptionsMethodRequired,
|
||||
ConnectMethodRequired,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure
|
|||
public const string Http10Version = "HTTP/1.0";
|
||||
public const string Http11Version = "HTTP/1.1";
|
||||
|
||||
public const string HttpUriScheme = "http://";
|
||||
public const string HttpsUriScheme = "https://";
|
||||
|
||||
// readonly primitive statics can be Jit'd to consts https://github.com/dotnet/coreclr/issues/1079
|
||||
|
||||
private readonly static ulong _httpSchemeLong = GetAsciiStringAsLong(HttpUriScheme + "\0");
|
||||
private readonly static ulong _httpsSchemeLong = GetAsciiStringAsLong(HttpsUriScheme);
|
||||
private readonly static ulong _httpConnectMethodLong = GetAsciiStringAsLong("CONNECT ");
|
||||
private readonly static ulong _httpDeleteMethodLong = GetAsciiStringAsLong("DELETE \0");
|
||||
private const uint _httpGetMethodInt = 542393671; // retun of GetAsciiStringAsInt("GET "); const results in better codegen
|
||||
|
|
@ -253,6 +259,34 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure
|
|||
return knownVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks 8 bytes from <paramref name="span"/> that correspond to 'http://' or 'https://'
|
||||
/// </summary>
|
||||
/// <param name="span">The span</param>
|
||||
/// <param name="knownScheme">A reference to the known scheme, if the input matches any</param>
|
||||
/// <returns>True when memory starts with known http or https schema</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool GetKnownHttpScheme(this Span<byte> span, out HttpScheme knownScheme)
|
||||
{
|
||||
if (span.TryRead<ulong>(out var value))
|
||||
{
|
||||
if ((value & _mask7Chars) == _httpSchemeLong)
|
||||
{
|
||||
knownScheme = HttpScheme.Http;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value == _httpsSchemeLong)
|
||||
{
|
||||
knownScheme = HttpScheme.Https;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
knownScheme = HttpScheme.Unknown;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string VersionToString(HttpVersion httpVersion)
|
||||
{
|
||||
switch (httpVersion)
|
||||
|
|
@ -274,5 +308,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string SchemeToString(HttpScheme scheme)
|
||||
{
|
||||
switch (scheme)
|
||||
{
|
||||
case HttpScheme.Http:
|
||||
return HttpUriScheme;
|
||||
case HttpScheme.Https:
|
||||
return HttpsUriScheme;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
// 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.
|
||||
|
||||
namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure
|
||||
{
|
||||
public class UriUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if character is valid in the 'authority' section of a URI.
|
||||
/// <see href="https://tools.ietf.org/html/rfc3986#section-3.2"/>
|
||||
/// </summary>
|
||||
/// <param name="ch">The character</param>
|
||||
/// <returns></returns>
|
||||
public static bool IsValidAuthorityCharacter(byte ch)
|
||||
{
|
||||
// Examples:
|
||||
// microsoft.com
|
||||
// hostname:8080
|
||||
// [::]:8080
|
||||
// [fe80::]
|
||||
// 127.0.0.1
|
||||
// user@host.com
|
||||
// user:password@host.com
|
||||
return
|
||||
(ch >= '0' && ch <= '9') ||
|
||||
(ch >= 'A' && ch <= 'Z') ||
|
||||
(ch >= 'a' && ch <= 'z') ||
|
||||
ch == ':' ||
|
||||
ch == '.' ||
|
||||
ch == '[' ||
|
||||
ch == ']' ||
|
||||
ch == '@';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure;
|
||||
using Microsoft.AspNetCore.Testing;
|
||||
|
|
@ -90,7 +91,19 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
$"Invalid content length: {contentLength}");
|
||||
}
|
||||
|
||||
private async Task TestBadRequest(string request, string expectedResponseStatusCode, string expectedExceptionMessage)
|
||||
[Theory]
|
||||
[InlineData("GET *", "OPTIONS")]
|
||||
[InlineData("GET www.host.com", "CONNECT")]
|
||||
public Task RejectsIncorrectMethods(string request, string allowedMethod)
|
||||
{
|
||||
return TestBadRequest(
|
||||
$"{request} HTTP/1.1\r\n",
|
||||
"405 Method Not Allowed",
|
||||
"Method not allowed.",
|
||||
$"Allow: {allowedMethod}");
|
||||
}
|
||||
|
||||
private async Task TestBadRequest(string request, string expectedResponseStatusCode, string expectedExceptionMessage, string expectedAllowHeader = null)
|
||||
{
|
||||
BadHttpRequestException loggedException = null;
|
||||
var mockKestrelTrace = new Mock<IKestrelTrace>();
|
||||
|
|
@ -106,7 +119,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
using (var connection = server.CreateConnection())
|
||||
{
|
||||
await connection.SendAll(request);
|
||||
await ReceiveBadRequestResponse(connection, expectedResponseStatusCode, server.Context.DateHeaderValue);
|
||||
await ReceiveBadRequestResponse(connection, expectedResponseStatusCode, server.Context.DateHeaderValue, expectedAllowHeader);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,15 +127,20 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
Assert.Equal(expectedExceptionMessage, loggedException.Message);
|
||||
}
|
||||
|
||||
private async Task ReceiveBadRequestResponse(TestConnection connection, string expectedResponseStatusCode, string expectedDateHeaderValue)
|
||||
private async Task ReceiveBadRequestResponse(TestConnection connection, string expectedResponseStatusCode, string expectedDateHeaderValue, string expectedAllowHeader = null)
|
||||
{
|
||||
await connection.ReceiveForcedEnd(
|
||||
var lines = new[]
|
||||
{
|
||||
$"HTTP/1.1 {expectedResponseStatusCode}",
|
||||
"Connection: close",
|
||||
$"Date: {expectedDateHeaderValue}",
|
||||
"Content-Length: 0",
|
||||
expectedAllowHeader,
|
||||
"",
|
||||
"");
|
||||
""
|
||||
};
|
||||
|
||||
await connection.ReceiveForcedEnd(lines.Where(f => f != null).ToArray());
|
||||
}
|
||||
|
||||
public static TheoryData<string, string> InvalidRequestLineData
|
||||
|
|
@ -131,9 +149,17 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
|
||||
string Escape(string line)
|
||||
{
|
||||
return line
|
||||
.Replace("\r", @"\x0D")
|
||||
.Replace("\n", @"\x0A")
|
||||
.Replace("\0", @"\x00");
|
||||
}
|
||||
|
||||
foreach (var requestLine in HttpParsingData.RequestLineInvalidData)
|
||||
{
|
||||
data.Add(requestLine, $"Invalid request line: '{requestLine.Replace("\r", "\\x0D").Replace("\n", "\\x0A")}'");
|
||||
data.Add(requestLine, $"Invalid request line: '{Escape(requestLine)}'");
|
||||
}
|
||||
|
||||
foreach (var requestLine in HttpParsingData.RequestLineWithEncodedNullCharInTargetData)
|
||||
|
|
@ -143,7 +169,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
|
||||
foreach (var requestLine in HttpParsingData.RequestLineWithNullCharInTargetData)
|
||||
{
|
||||
data.Add(requestLine, "Invalid request line.");
|
||||
data.Add(requestLine, $"Invalid request line.");
|
||||
}
|
||||
|
||||
return data;
|
||||
|
|
|
|||
|
|
@ -454,6 +454,72 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
|
|||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://localhost/abs/path", "/abs/path", null)]
|
||||
[InlineData("https://localhost/abs/path", "/abs/path", null)] // handles mismatch scheme
|
||||
[InlineData("https://localhost:22/abs/path", "/abs/path", null)] // handles mismatched ports
|
||||
[InlineData("https://differenthost/abs/path", "/abs/path", null)] // handles mismatched hostname
|
||||
[InlineData("http://localhost/", "/", null)]
|
||||
[InlineData("http://root@contoso.com/path", "/path", null)]
|
||||
[InlineData("http://root:password@contoso.com/path", "/path", null)]
|
||||
[InlineData("https://localhost/", "/", null)]
|
||||
[InlineData("http://localhost", "/", null)]
|
||||
[InlineData("http://127.0.0.1/", "/", null)]
|
||||
[InlineData("http://[::1]/", "/", null)]
|
||||
[InlineData("http://[::1]:8080/", "/", null)]
|
||||
[InlineData("http://localhost?q=123&w=xyz", "/", "123")]
|
||||
[InlineData("http://localhost/?q=123&w=xyz", "/", "123")]
|
||||
[InlineData("http://localhost/path?q=123&w=xyz", "/path", "123")]
|
||||
[InlineData("http://localhost/path%20with%20space?q=abc%20123", "/path with space", "abc 123")]
|
||||
public async Task CanHandleRequestsWithUrlInAbsoluteForm(string requestUrl, string expectedPath, string queryValue)
|
||||
{
|
||||
var pathTcs = new TaskCompletionSource<PathString>();
|
||||
var rawTargetTcs = new TaskCompletionSource<string>();
|
||||
var hostTcs = new TaskCompletionSource<HostString>();
|
||||
var queryTcs = new TaskCompletionSource<IQueryCollection>();
|
||||
|
||||
using (var server = new TestServer(async context =>
|
||||
{
|
||||
pathTcs.TrySetResult(context.Request.Path);
|
||||
hostTcs.TrySetResult(context.Request.Host);
|
||||
queryTcs.TrySetResult(context.Request.Query);
|
||||
rawTargetTcs.TrySetResult(context.Features.Get<IHttpRequestFeature>().RawTarget);
|
||||
await context.Response.WriteAsync("Done");
|
||||
}))
|
||||
{
|
||||
using (var connection = server.CreateConnection())
|
||||
{
|
||||
await connection.Send(
|
||||
$"GET {requestUrl} HTTP/1.1",
|
||||
"Content-Length: 0",
|
||||
"Host: localhost",
|
||||
"",
|
||||
"");
|
||||
|
||||
await connection.Receive($"HTTP/1.1 200 OK",
|
||||
$"Date: {server.Context.DateHeaderValue}",
|
||||
"Transfer-Encoding: chunked",
|
||||
"",
|
||||
"4",
|
||||
"Done")
|
||||
.TimeoutAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
await Task.WhenAll(pathTcs.Task, rawTargetTcs.Task, hostTcs.Task, queryTcs.Task).TimeoutAfter(TimeSpan.FromSeconds(30));
|
||||
Assert.Equal(new PathString(expectedPath), pathTcs.Task.Result);
|
||||
Assert.Equal(requestUrl, rawTargetTcs.Task.Result);
|
||||
Assert.Equal("localhost", hostTcs.Task.Result.ToString());
|
||||
if (queryValue == null)
|
||||
{
|
||||
Assert.False(queryTcs.Task.Result.ContainsKey("q"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(queryValue, queryTcs.Task.Result["q"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestRemoteIPAddress(string registerAddress, string requestAddress, string expectAddress)
|
||||
{
|
||||
var builder = new WebHostBuilder()
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Performance
|
|||
|
||||
private class NullParser : IHttpParser
|
||||
{
|
||||
private readonly byte[] _startLine = Encoding.ASCII.GetBytes("GET /plaintext HTTP/1.1\r\n");
|
||||
private readonly byte[] _target = Encoding.ASCII.GetBytes("/plaintext");
|
||||
private readonly byte[] _hostHeaderName = Encoding.ASCII.GetBytes("Host");
|
||||
private readonly byte[] _hostHeaderValue = Encoding.ASCII.GetBytes("www.example.com");
|
||||
|
|
@ -119,7 +120,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Performance
|
|||
|
||||
public bool ParseRequestLine<T>(T handler, ReadableBuffer buffer, out ReadCursor consumed, out ReadCursor examined) where T : IHttpRequestLineHandler
|
||||
{
|
||||
handler.OnStartLine(HttpMethod.Get, HttpVersion.Http11, new Span<byte>(_target), new Span<byte>(_target), Span<byte>.Empty, Span<byte>.Empty, false);
|
||||
handler.OnStartLine(HttpMethod.Get,
|
||||
HttpVersion.Http11,
|
||||
new Span<byte>(_target),
|
||||
new Span<byte>(_target),
|
||||
Span<byte>.Empty,
|
||||
Span<byte>.Empty,
|
||||
new Span<byte>(_startLine),
|
||||
false);
|
||||
|
||||
consumed = buffer.Start;
|
||||
examined = buffer.End;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Performance
|
|||
}
|
||||
}
|
||||
|
||||
public void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, bool pathEncoded)
|
||||
public void OnStartLine(HttpMethod method, HttpVersion version, Span<byte> target, Span<byte> path, Span<byte> query, Span<byte> customMethod, Span<byte> line, bool pathEncoded)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Http.Features;
|
|||
using Microsoft.AspNetCore.Server.Kestrel;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Http;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure;
|
||||
using Microsoft.AspNetCore.Testing;
|
||||
using Microsoft.Extensions.Internal;
|
||||
using Moq;
|
||||
|
|
@ -328,6 +329,37 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
Assert.Equal(new InvalidOperationException().Message, exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RequestLineWithInvalidRequestTargetData))]
|
||||
public async Task TakeStartLineThrowsWhenRequestTargetIsInvalid(string requestLine)
|
||||
{
|
||||
await _input.Writer.WriteAsync(Encoding.ASCII.GetBytes(requestLine));
|
||||
var readableBuffer = (await _input.Reader.ReadAsync()).Buffer;
|
||||
|
||||
var exception = Assert.Throws<BadHttpRequestException>(() =>
|
||||
_frame.TakeStartLine(readableBuffer, out _consumed, out _examined));
|
||||
_input.Reader.Advance(_consumed, _examined);
|
||||
|
||||
Assert.Equal($"Invalid request line: '{Escape(requestLine)}'", exception.Message);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MethodNotAllowedTargetData))]
|
||||
public async Task TakeStartLineThrowsWhenMethodNotAllowed(string requestLine, HttpMethod allowedMethod)
|
||||
{
|
||||
await _input.Writer.WriteAsync(Encoding.ASCII.GetBytes(requestLine));
|
||||
var readableBuffer = (await _input.Reader.ReadAsync()).Buffer;
|
||||
|
||||
var exception = Assert.Throws<BadHttpRequestException>(() =>
|
||||
_frame.TakeStartLine(readableBuffer, out _consumed, out _examined));
|
||||
_input.Reader.Advance(_consumed, _examined);
|
||||
|
||||
Assert.Equal(405, exception.StatusCode);
|
||||
Assert.Equal("Method not allowed.", exception.Message);
|
||||
Assert.Equal(HttpUtilities.MethodToString(allowedMethod), exception.AllowedHeader);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestProcessingAsyncEnablesKeepAliveTimeout()
|
||||
{
|
||||
|
|
@ -516,6 +548,25 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
}
|
||||
}
|
||||
|
||||
private string Escape(string requestLine)
|
||||
{
|
||||
var ellipsis = requestLine.Length > 32
|
||||
? "..."
|
||||
: string.Empty;
|
||||
return requestLine
|
||||
.Substring(0, Math.Min(32, requestLine.Length))
|
||||
.Replace("\r", @"\x0D")
|
||||
.Replace("\n", @"\x0A")
|
||||
.Replace("\0", @"\x00")
|
||||
+ ellipsis;
|
||||
}
|
||||
|
||||
public static TheoryData<string> RequestLineWithInvalidRequestTargetData
|
||||
=> HttpParsingData.RequestLineWithInvalidRequestTarget;
|
||||
|
||||
public static TheoryData<string, HttpMethod> MethodNotAllowedTargetData
|
||||
=> HttpParsingData.MethodNotAllowedRequestLine;
|
||||
|
||||
public static TheoryData<string> RequestLineWithNullCharInTargetData
|
||||
{
|
||||
get
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
It.IsAny<Span<byte>>(),
|
||||
It.IsAny<Span<byte>>(),
|
||||
It.IsAny<Span<byte>>(),
|
||||
It.IsAny<Span<byte>>(),
|
||||
It.IsAny<bool>()))
|
||||
.Callback<HttpMethod, HttpVersion, Span<byte>, Span<byte>, Span<byte>, Span<byte>, bool>((method, version, target, path, query, customMethod, pathEncoded) =>
|
||||
.Callback<HttpMethod, HttpVersion, Span<byte>, Span<byte>, Span<byte>, Span<byte>, Span<byte>, bool>((method, version, target, path, query, customMethod, line, pathEncoded) =>
|
||||
{
|
||||
parsedMethod = method != HttpMethod.Custom ? HttpUtilities.MethodToString(method) : customMethod.GetAsciiStringNonNullCharacters();
|
||||
parsedVersion = HttpUtilities.VersionToString(version);
|
||||
|
|
|
|||
|
|
@ -84,11 +84,23 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
{
|
||||
TestKnownStringsInterning(input, expected, span =>
|
||||
{
|
||||
HttpUtilities.GetKnownVersion(span, out var version, out var lenght);
|
||||
HttpUtilities.GetKnownVersion(span, out var version, out var _);
|
||||
return HttpUtilities.VersionToString(version);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://host/", "https://")]
|
||||
[InlineData("http://host/", "http://")]
|
||||
public void KnownSchemesAreInterned(string input, string expected)
|
||||
{
|
||||
TestKnownStringsInterning(input, expected, span =>
|
||||
{
|
||||
HttpUtilities.GetKnownHttpScheme(span, out var scheme);
|
||||
return HttpUtilities.SchemeToString(scheme);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CONNECT / HTTP/1.1", "CONNECT")]
|
||||
[InlineData("DELETE / HTTP/1.1", "DELETE")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ using System.Threading.Tasks;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Server.Kestrel;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Http;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Internal.Infrastructure;
|
||||
using Microsoft.AspNetCore.Testing;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -93,19 +95,9 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("*/?arg=value")]
|
||||
[InlineData("*?arg=value")]
|
||||
[InlineData("DoesNotStartWith/")]
|
||||
[InlineData("DoesNotStartWith/?arg=value")]
|
||||
[InlineData("DoesNotStartWithSlash?arg=value")]
|
||||
[InlineData("./")]
|
||||
[InlineData("../")]
|
||||
[InlineData("../.")]
|
||||
[InlineData(".././")]
|
||||
[InlineData("../..")]
|
||||
[InlineData("../../")]
|
||||
public async Task NonPathRequestTargetSetInRawTarget(string requestTarget)
|
||||
[InlineData(HttpMethod.Options, "*")]
|
||||
[InlineData(HttpMethod.Connect, "host")]
|
||||
public async Task NonPathRequestTargetSetInRawTarget(HttpMethod method, string requestTarget)
|
||||
{
|
||||
var testContext = new TestServiceContext();
|
||||
|
||||
|
|
@ -123,7 +115,7 @@ namespace Microsoft.AspNetCore.Server.KestrelTests
|
|||
using (var connection = server.CreateConnection())
|
||||
{
|
||||
await connection.Send(
|
||||
$"GET {requestTarget} HTTP/1.1",
|
||||
$"{HttpUtilities.MethodToString(method)} {requestTarget} HTTP/1.1",
|
||||
"",
|
||||
"");
|
||||
await connection.ReceiveEnd(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// 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 Microsoft.AspNetCore.Server.Kestrel.Internal.Http;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
|
@ -33,6 +34,26 @@ namespace Microsoft.AspNetCore.Testing
|
|||
Tuple.Create("/%C3%A5/bc", "/\u00E5/bc"),
|
||||
Tuple.Create("/%25", "/%"),
|
||||
Tuple.Create("/%2F", "/%2F"),
|
||||
Tuple.Create("http://host/abs/path", "/abs/path"),
|
||||
Tuple.Create("http://host/abs/path/", "/abs/path/"),
|
||||
Tuple.Create("http://host/a%20b%20c/", "/a b c/"),
|
||||
Tuple.Create("https://host/abs/path", "/abs/path"),
|
||||
Tuple.Create("https://host/abs/path/", "/abs/path/"),
|
||||
Tuple.Create("https://host:22/abs/path", "/abs/path"),
|
||||
Tuple.Create("https://user@host:9080/abs/path", "/abs/path"),
|
||||
Tuple.Create("http://host/", "/"),
|
||||
Tuple.Create("http://host", "/"),
|
||||
Tuple.Create("https://host/", "/"),
|
||||
Tuple.Create("https://host", "/"),
|
||||
Tuple.Create("http://user@host/", "/"),
|
||||
Tuple.Create("http://127.0.0.1/", "/"),
|
||||
Tuple.Create("http://user@127.0.0.1/", "/"),
|
||||
Tuple.Create("http://user@127.0.0.1:8080/", "/"),
|
||||
Tuple.Create("http://127.0.0.1:8080/", "/"),
|
||||
Tuple.Create("http://[::1]", "/"),
|
||||
Tuple.Create("http://[::1]/path", "/path"),
|
||||
Tuple.Create("http://[::1]:8080/", "/"),
|
||||
Tuple.Create("http://user@[::1]:8080/", "/"),
|
||||
};
|
||||
var queryStrings = new[]
|
||||
{
|
||||
|
|
@ -173,9 +194,73 @@ namespace Microsoft.AspNetCore.Testing
|
|||
"GET /%E8%01%00 HTTP/1.1\r\n",
|
||||
};
|
||||
|
||||
public static TheoryData<string> RequestLineWithInvalidRequestTarget => new TheoryData<string>
|
||||
{
|
||||
// Invalid absolute-form requests
|
||||
"GET http:// HTTP/1.1\r\n",
|
||||
"GET http:/ HTTP/1.1\r\n",
|
||||
"GET https:/ HTTP/1.1\r\n",
|
||||
"GET http:/// HTTP/1.1\r\n",
|
||||
"GET https:// HTTP/1.1\r\n",
|
||||
"GET http://// HTTP/1.1\r\n",
|
||||
"GET http://:80 HTTP/1.1\r\n",
|
||||
"GET http://:80/abc HTTP/1.1\r\n",
|
||||
"GET http://user@ HTTP/1.1\r\n",
|
||||
"GET http://user@/abc HTTP/1.1\r\n",
|
||||
"GET http://abc%20xyz/abc HTTP/1.1\r\n",
|
||||
"GET http://%20/abc?query=%0A HTTP/1.1\r\n",
|
||||
// Valid absolute-form but with unsupported schemes
|
||||
"GET otherscheme://host/ HTTP/1.1\r\n",
|
||||
"GET ws://host/ HTTP/1.1\r\n",
|
||||
"GET wss://host/ HTTP/1.1\r\n",
|
||||
// Must only have one asterisk
|
||||
"OPTIONS ** HTTP/1.1\r\n",
|
||||
// Relative form
|
||||
"GET ../../ HTTP/1.1\r\n",
|
||||
"GET ..\\. HTTP/1.1\r\n",
|
||||
};
|
||||
|
||||
public static TheoryData<string, HttpMethod> MethodNotAllowedRequestLine
|
||||
{
|
||||
get
|
||||
{
|
||||
var methods = new[]
|
||||
{
|
||||
"GET",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"POST",
|
||||
"HEAD",
|
||||
"TRACE",
|
||||
"PATCH",
|
||||
"CONNECT",
|
||||
//"OPTIONS",
|
||||
"CUSTOM",
|
||||
};
|
||||
|
||||
var theoryData = new TheoryData<string, HttpMethod>();
|
||||
foreach (var line in methods
|
||||
.Select(m => Tuple.Create($"{m} * HTTP/1.1\r\n", HttpMethod.Options))
|
||||
.Concat(new[]
|
||||
{
|
||||
// CONNECT required for authority-form targets
|
||||
Tuple.Create("GET http:80 HTTP/1.1\r\n", HttpMethod.Connect),
|
||||
Tuple.Create("GET http: HTTP/1.1\r\n", HttpMethod.Connect),
|
||||
Tuple.Create("GET https: HTTP/1.1\r\n", HttpMethod.Connect),
|
||||
Tuple.Create("GET . HTTP/1.1\r\n", HttpMethod.Connect),
|
||||
}))
|
||||
{
|
||||
theoryData.Add(line.Item1, line.Item2);
|
||||
}
|
||||
|
||||
return theoryData;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<string> RequestLineWithNullCharInTargetData => new[]
|
||||
{
|
||||
"GET \0 HTTP/1.1\r\n",
|
||||
// TODO re-enable after we get both #1469 and #1470 merged
|
||||
// "GET \0 HTTP/1.1\r\n",
|
||||
"GET /\0 HTTP/1.1\r\n",
|
||||
"GET /\0\0 HTTP/1.1\r\n",
|
||||
"GET /%C8\0 HTTP/1.1\r\n",
|
||||
|
|
@ -183,6 +268,8 @@ namespace Microsoft.AspNetCore.Testing
|
|||
|
||||
public static TheoryData<string> UnrecognizedHttpVersionData => new TheoryData<string>
|
||||
{
|
||||
" ",
|
||||
"/",
|
||||
"H",
|
||||
"HT",
|
||||
"HTT",
|
||||
|
|
|
|||
Loading…
Reference in New Issue