From 50208a3a79a2171ef03eea85e9c779af4916bca9 Mon Sep 17 00:00:00 2001 From: Cesar Blum Silveira Date: Wed, 25 May 2016 15:55:07 -0700 Subject: [PATCH] Implement IHttpRequestFeature.RawTarget (aspnet/HttpAbstractions#596). --- .../Http/Frame.FeatureCollection.cs | 12 ++ .../Http/Frame.cs | 48 +++++-- .../RequestTests.cs | 46 ------ .../TestConnection.cs | 35 ----- .../RequestTargetProcessingTests.cs | 131 ++++++++++++++++++ 5 files changed, 183 insertions(+), 89 deletions(-) delete mode 100644 test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/TestConnection.cs create mode 100644 test/Microsoft.AspNetCore.Server.KestrelTests/RequestTargetProcessingTests.cs diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.FeatureCollection.cs b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.FeatureCollection.cs index fbd2c43cde..dc452096c3 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.FeatureCollection.cs +++ b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.FeatureCollection.cs @@ -152,6 +152,18 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http } } + string IHttpRequestFeature.RawTarget + { + get + { + return RawTarget; + } + set + { + RawTarget = value; + } + } + IHeaderDictionary IHttpRequestFeature.Headers { get diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs index c9f77bc273..7868964958 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs +++ b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs @@ -84,6 +84,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http public string PathBase { get; set; } public string Path { get; set; } public string QueryString { get; set; } + public string RawTarget { get; set; } public string HttpVersion { get @@ -860,6 +861,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http queryString = begin.GetAsciiString(scan); } + var queryEnd = scan; + if (pathBegin.Peek() == ' ') { RejectRequest("Missing request target."); @@ -907,8 +910,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http // 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; + string rawTarget; if (needDecode) { + // Read raw target before mutating memory. + rawTarget = pathBegin.GetAsciiString(queryEnd); + // URI was encoded, unescape and then parse as utf8 pathEnd = UrlPathDecoder.Unescape(pathBegin, pathEnd); requestUrlPath = pathBegin.GetUtf8String(pathEnd); @@ -918,27 +925,42 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http { // URI wasn't encoded, parse as ASCII requestUrlPath = pathBegin.GetAsciiString(pathEnd); + + if (queryString.Length == 0) + { + // No need to allocate an extra string if the path didn't need + // decoding and there's no query string following it. + rawTarget = requestUrlPath; + } + else + { + rawTarget = pathBegin.GetAsciiString(queryEnd); + } } - requestUrlPath = PathNormalizer.RemoveDotSegments(requestUrlPath); + var normalizedTarget = PathNormalizer.RemoveDotSegments(requestUrlPath); consumed = scan; Method = method; QueryString = queryString; + RawTarget = rawTarget; HttpVersion = httpVersion; bool caseMatches; - - if (!string.IsNullOrEmpty(_pathBase) && - (requestUrlPath.Length == _pathBase.Length || (requestUrlPath.Length > _pathBase.Length && requestUrlPath[_pathBase.Length] == '/')) && - RequestUrlStartsWithPathBase(requestUrlPath, out caseMatches)) + if (RequestUrlStartsWithPathBase(normalizedTarget, out caseMatches)) { - PathBase = caseMatches ? _pathBase : requestUrlPath.Substring(0, _pathBase.Length); - Path = requestUrlPath.Substring(_pathBase.Length); + 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 = requestUrlPath; + Path = string.Empty; + PathBase = string.Empty; + QueryString = string.Empty; } return RequestLineStatus.Done; @@ -978,6 +1000,16 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Http { caseMatches = true; + if (string.IsNullOrEmpty(_pathBase)) + { + return false; + } + + if (requestUrl.Length < _pathBase.Length || (requestUrl.Length > _pathBase.Length && requestUrl[_pathBase.Length] != '/')) + { + return false; + } + for (var i = 0; i < _pathBase.Length; i++) { if (requestUrl[i] != _pathBase[i]) diff --git a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs index 3ec8eead7d..6f4f3a2d45 100644 --- a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs +++ b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs @@ -4,8 +4,6 @@ using System; using System.Globalization; using System.Net.Http; -using System.Net.Sockets; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -158,50 +156,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests } } - [Fact] - public void RequestPathIsNormalized() - { - var builder = new WebHostBuilder() - .UseKestrel() - .UseUrls($"http://127.0.0.1:0/\u0041\u030A") - .Configure(app => - { - app.Run(async context => - { - var connection = context.Connection; - Assert.Equal("/\u00C5", context.Request.PathBase.Value); - Assert.Equal("/B/\u00C5", context.Request.Path.Value); - await context.Response.WriteAsync("hello, world"); - }); - }); - - using (var host = builder.Build()) - { - host.Start(); - - using (var socket = TestConnection.CreateConnectedLoopbackSocket(host.GetPort())) - { - socket.Send(Encoding.ASCII.GetBytes("GET /%41%CC%8A/A/../B/%41%CC%8A HTTP/1.1\r\n\r\n")); - socket.Shutdown(SocketShutdown.Send); - - var response = new StringBuilder(); - var buffer = new byte[4096]; - while (true) - { - var length = socket.Receive(buffer); - if (length == 0) - { - break; - } - - response.Append(Encoding.ASCII.GetString(buffer, 0, length)); - } - - Assert.StartsWith("HTTP/1.1 200 OK", response.ToString()); - } - } - } - private async Task TestRemoteIPAddress(string registerAddress, string requestAddress, string expectAddress) { var builder = new WebHostBuilder() diff --git a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/TestConnection.cs b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/TestConnection.cs deleted file mode 100644 index db602658f0..0000000000 --- a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/TestConnection.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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.Net; -using System.Net.Sockets; -using Microsoft.AspNetCore.Server.Kestrel.Networking; - -namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests -{ - public class TestConnection - { - public static Socket CreateConnectedLoopbackSocket(int port) - { - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - if (PlatformApis.IsWindows) - { - const int SIO_LOOPBACK_FAST_PATH = -1744830448; - var optionInValue = BitConverter.GetBytes(1); - try - { - socket.IOControl(SIO_LOOPBACK_FAST_PATH, optionInValue, null); - } - catch - { - // If the operating system version on this machine did - // not support SIO_LOOPBACK_FAST_PATH (i.e. version - // prior to Windows 8 / Windows Server 2012), handle the exception - } - } - socket.Connect(new IPEndPoint(IPAddress.Loopback, port)); - return socket; - } - } -} diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/RequestTargetProcessingTests.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/RequestTargetProcessingTests.cs new file mode 100644 index 0000000000..ac2327cea6 --- /dev/null +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/RequestTargetProcessingTests.cs @@ -0,0 +1,131 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Xunit; + +namespace Microsoft.AspNetCore.Server.KestrelTests +{ + public class RequestTargetProcessingTests + { + [Fact] + public async Task RequestPathIsNormalized() + { + var testContext = new TestServiceContext(); + + using (var server = new TestServer(async context => + { + Assert.Equal("/\u00C5", context.Request.PathBase.Value); + Assert.Equal("/B/\u00C5", context.Request.Path.Value); + + context.Response.Headers["Content-Length"] = new[] { "11" }; + await context.Response.WriteAsync("Hello World"); + }, testContext, "http://127.0.0.1/\u0041\u030A")) + { + using (var connection = server.CreateConnection()) + { + await connection.SendEnd( + "GET /%41%CC%8A/A/../B/%41%CC%8A HTTP/1.0", + "", + ""); + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 11", + "", + "Hello World"); + } + } + } + + [Theory] + [InlineData("/")] + [InlineData("/.")] + [InlineData("/..")] + [InlineData("/./.")] + [InlineData("/./..")] + [InlineData("/../.")] + [InlineData("/../..")] + [InlineData("/path")] + [InlineData("/path?foo=1&bar=2")] + [InlineData("/hello%20world")] + [InlineData("/hello%20world?foo=1&bar=2")] + [InlineData("/base/path")] + [InlineData("/base/path?foo=1&bar=2")] + [InlineData("/base/hello%20world")] + [InlineData("/base/hello%20world?foo=1&bar=2")] + public async Task RequestFeatureContainsRawTarget(string requestTarget) + { + var testContext = new TestServiceContext(); + + using (var server = new TestServer(async context => + { + Assert.Equal(requestTarget, context.Features.Get().RawTarget); + + context.Response.Headers["Content-Length"] = new[] { "11" }; + await context.Response.WriteAsync("Hello World"); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.SendEnd( + $"GET {requestTarget} HTTP/1.0", + "", + ""); + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 11", + "", + "Hello World"); + } + } + } + + [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) + { + var testContext = new TestServiceContext(); + + using (var server = new TestServer(async context => + { + Assert.Equal(requestTarget, context.Features.Get().RawTarget); + Assert.Empty(context.Request.Path.Value); + Assert.Empty(context.Request.PathBase.Value); + Assert.Empty(context.Request.QueryString.Value); + + context.Response.Headers["Content-Length"] = new[] { "11" }; + await context.Response.WriteAsync("Hello World"); + }, testContext)) + { + using (var connection = server.CreateConnection()) + { + await connection.SendEnd( + $"GET {requestTarget} HTTP/1.0", + "", + ""); + await connection.ReceiveEnd( + "HTTP/1.1 200 OK", + $"Date: {testContext.DateHeaderValue}", + "Content-Length: 11", + "", + "Hello World"); + } + } + } + } +}