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:
Nate McMaster 2017-03-09 16:54:12 -08:00
parent 941d396942
commit 49b328d4c2
17 changed files with 557 additions and 54 deletions

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -23,6 +23,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Internal.Http
RequestTimeout,
FinalTransferCodingNotChunked,
LengthRequired,
LengthRequiredHttp10
LengthRequiredHttp10,
OptionsMethodRequired,
ConnectMethodRequired,
}
}

View File

@ -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;
}
}
}
}

View File

@ -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 == '@';
}
}
}

View File

@ -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;

View File

@ -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()

View File

@ -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;

View File

@ -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)
{
}

View File

@ -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

View File

@ -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);

View File

@ -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")]

View File

@ -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(

View File

@ -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",