From 1209eca3fa755da019244ff3da4998e253c23bae Mon Sep 17 00:00:00 2001 From: Cesar Blum Silveira Date: Fri, 8 Jan 2016 15:41:53 -0800 Subject: [PATCH] Normalize request path to NFC and resolve dot segments (#273). --- .../Http/Frame.cs | 3 + .../Http/PathNormalizer.cs | 132 ++++++++++++++++++ .../ServerAddress.cs | 3 + .../project.json | 1 + .../RequestTests.cs | 60 +++++++- .../EngineTests.cs | 2 +- .../PathNormalizerTests.cs | 63 +++++++++ .../ServerAddressFacts.cs | 14 ++ .../project.json | 2 + 9 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Server.Kestrel/Http/PathNormalizer.cs create mode 100644 test/Microsoft.AspNetCore.Server.KestrelTests/PathNormalizerTests.cs diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs index 0c83003b8..81aa57bb5 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs +++ b/src/Microsoft.AspNetCore.Server.Kestrel/Http/Frame.cs @@ -741,6 +741,7 @@ protected bool TakeStartLine(SocketInput input) // URI was encoded, unescape and then parse as utf8 pathEnd = UrlPathDecoder.Unescape(pathBegin, pathEnd); requestUrlPath = pathBegin.GetUtf8String(pathEnd); + requestUrlPath = PathNormalizer.NormalizeToNFC(requestUrlPath); } else { @@ -748,6 +749,8 @@ protected bool TakeStartLine(SocketInput input) requestUrlPath = pathBegin.GetAsciiString(pathEnd); } + requestUrlPath = PathNormalizer.RemoveDotSegments(requestUrlPath); + consumed = scan; Method = method; RequestUri = requestUrlPath; diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/Http/PathNormalizer.cs b/src/Microsoft.AspNetCore.Server.Kestrel/Http/PathNormalizer.cs new file mode 100644 index 000000000..e136331e2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Kestrel/Http/PathNormalizer.cs @@ -0,0 +1,132 @@ +// 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.Text; + +namespace Microsoft.AspNetCore.Server.Kestrel.Http +{ + public static class PathNormalizer + { + public static string NormalizeToNFC(string path) + { + if (!path.IsNormalized(NormalizationForm.FormC)) + { + path = path.Normalize(NormalizationForm.FormC); + } + + return path; + } + + public static string RemoveDotSegments(string path) + { + if (ContainsDotSegments(path)) + { + var normalizedChars = ArrayPool.Shared.Rent(path.Length); + var normalizedIndex = normalizedChars.Length; + var pathIndex = path.Length - 1; + var skipSegments = 0; + + while (pathIndex >= 0) + { + if (pathIndex >= 2 && path[pathIndex] == '.' && path[pathIndex - 1] == '.' && path[pathIndex - 2] == '/') + { + if (normalizedIndex == normalizedChars.Length || normalizedChars[normalizedIndex] != '/') + { + normalizedChars[--normalizedIndex] = '/'; + } + + skipSegments++; + pathIndex -= 3; + } + else if (pathIndex >= 1 && path[pathIndex] == '.' && path[pathIndex - 1] == '/') + { + pathIndex -= 2; + } + else + { + while (pathIndex >= 0) + { + var lastChar = path[pathIndex]; + + if (skipSegments == 0) + { + normalizedChars[--normalizedIndex] = lastChar; + } + + pathIndex--; + + if (lastChar == '/') + { + break; + } + } + + if (skipSegments > 0) + { + skipSegments--; + } + } + } + + path = new string(normalizedChars, normalizedIndex, normalizedChars.Length - normalizedIndex); + ArrayPool.Shared.Return(normalizedChars); + } + + return path; + } + + private unsafe static bool ContainsDotSegments(string path) + { + fixed (char* ptr = path) + { + char* end = ptr + path.Length; + + for (char* p = ptr; p < end; p++) + { + if (*p == '/') + { + p++; + } + + if (p == end) + { + return false; + } + + if (*p == '.') + { + p++; + + if (p == end) + { + return true; + } + + if (*p == '.') + { + p++; + + if (p == end) + { + return true; + } + + if (*p == '/') + { + return true; + } + } + else if (*p == '/') + { + return true; + } + } + } + } + + return false; + } + } +} diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/ServerAddress.cs b/src/Microsoft.AspNetCore.Server.Kestrel/ServerAddress.cs index 4bbc6dc9c..a41472bbd 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/ServerAddress.cs +++ b/src/Microsoft.AspNetCore.Server.Kestrel/ServerAddress.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Globalization; +using Microsoft.AspNetCore.Server.Kestrel.Http; using Microsoft.AspNetCore.Server.Kestrel.Infrastructure; namespace Microsoft.AspNetCore.Server.Kestrel @@ -147,6 +148,8 @@ public static ServerAddress FromUrl(string url) serverAddress.PathBase = url.Substring(pathDelimiterEnd); } + serverAddress.PathBase = PathNormalizer.NormalizeToNFC(serverAddress.PathBase); + return serverAddress; } } diff --git a/src/Microsoft.AspNetCore.Server.Kestrel/project.json b/src/Microsoft.AspNetCore.Server.Kestrel/project.json index dcf935bc9..82d6c9233 100644 --- a/src/Microsoft.AspNetCore.Server.Kestrel/project.json +++ b/src/Microsoft.AspNetCore.Server.Kestrel/project.json @@ -6,6 +6,7 @@ "url": "git://github.com/aspnet/kestrelhttpserver" }, "dependencies": { + "System.Buffers": "4.0.0-*", "Microsoft.AspNetCore.Hosting": "1.0.0-*", "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*", "Microsoft.Extensions.PlatformAbstractions": "1.0.0-*", diff --git a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs index 31e8ce1e2..25486918c 100644 --- a/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs +++ b/test/Microsoft.AspNetCore.Server.Kestrel.FunctionalTests/RequestTests.cs @@ -3,7 +3,10 @@ using System.Collections.Generic; using System.Globalization; +using System.Net; using System.Net.Http; +using System.Net.Sockets; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -88,7 +91,7 @@ public Task RemoteIPv4Address(string requestAddress, string expectAddress, strin [IPv6SupportedCondition] public Task RemoteIPv6Address() { - return TestRemoteIPAddress("[::1]", "[::1]", "::1", "8792"); + return TestRemoteIPAddress("[::1]", "[::1]", "::1", "8793"); } [ConditionalFact] @@ -97,7 +100,7 @@ public async Task DoesNotHangOnConnectionCloseRequest() { var config = new ConfigurationBuilder().AddInMemoryCollection( new Dictionary { - { "server.urls", "http://localhost:8791" } + { "server.urls", "http://localhost:8794" } }).Build(); var builder = new WebHostBuilder() @@ -120,11 +123,62 @@ public async Task DoesNotHangOnConnectionCloseRequest() client.DefaultRequestHeaders.Connection.Clear(); client.DefaultRequestHeaders.Connection.Add("close"); - var response = await client.GetAsync("http://localhost:8791/"); + var response = await client.GetAsync("http://localhost:8794/"); response.EnsureSuccessStatusCode(); } } + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.Mono, SkipReason = "Test hangs after execution on Mono.")] + public async Task RequestPathIsNormalized() + { + var config = new ConfigurationBuilder().AddInMemoryCollection( + new Dictionary { + { "server.urls", "http://localhost:8795/\u0041\u030A" } + }).Build(); + + var builder = new WebHostBuilder() + .UseConfiguration(config) + .UseServer("Microsoft.AspNetCore.Server.Kestrel") + .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 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + socket.Connect(new IPEndPoint(IPAddress.Loopback, 8795)); + 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, string port) { var config = new ConfigurationBuilder().AddInMemoryCollection( diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/EngineTests.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/EngineTests.cs index 920e3151a..95e9d304b 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/EngineTests.cs +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/EngineTests.cs @@ -1120,7 +1120,7 @@ public async Task FailedWritesResultInAbortedRequest(ServiceContext testContext) try { // Ensure write is long enough to disable write-behind buffering - for (int i = 0; i < 10; i++) + for (int i = 0; i < 100; i++) { await response.WriteAsync(largeString, lifetime.RequestAborted); } diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/PathNormalizerTests.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/PathNormalizerTests.cs new file mode 100644 index 000000000..a2304301e --- /dev/null +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/PathNormalizerTests.cs @@ -0,0 +1,63 @@ +// 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.Text; +using Microsoft.AspNetCore.Server.Kestrel.Http; +using Xunit; + +namespace Microsoft.AspNetCore.Server.KestrelTests +{ + public class PathNormalizerTests + { + [Theory] + [InlineData("/a", "/a")] + [InlineData("/a/", "/a/")] + [InlineData("/a/b", "/a/b")] + [InlineData("/a/b/", "/a/b/")] + [InlineData("/a", "/./a")] + [InlineData("/a", "/././a")] + [InlineData("/a", "/../a")] + [InlineData("/a", "/../../a")] + [InlineData("/a/b", "/a/./b")] + [InlineData("/b", "/a/../b")] + [InlineData("/a/", "/a/./")] + [InlineData("/a", "/a/.")] + [InlineData("/", "/a/../b/../")] + [InlineData("/", "/a/../b/..")] + [InlineData("/b", "/a/../../b")] + [InlineData("/b/", "/a/../../b/")] + [InlineData("/b", "/a/.././../b")] + [InlineData("/b/", "/a/.././../b/")] + [InlineData("/a/d", "/a/b/c/./../../d")] + [InlineData("/a/d", "/./a/b/c/./../../d")] + [InlineData("/a/d", "/../a/b/c/./../../d")] + [InlineData("/a/d", "/./../a/b/c/./../../d")] + [InlineData("/a/d", "/.././a/b/c/./../../d")] + [InlineData("/.a", "/.a")] + [InlineData("/..a", "/..a")] + [InlineData("/...", "/...")] + [InlineData("/a/.../b", "/a/.../b")] + [InlineData("/b", "/a/../.../../b")] + [InlineData("/a/.b", "/a/.b")] + [InlineData("/a/..b", "/a/..b")] + [InlineData("/a/b.", "/a/b.")] + [InlineData("/a/b..", "/a/b..")] + [InlineData("a/b", "a/b")] + [InlineData("a/c", "a/b/../c")] + [InlineData("*", "*")] + public void RemovesDotSegments(string expected, string input) + { + var result = PathNormalizer.RemoveDotSegments(input); + Assert.Equal(expected, result); + } + + [Fact] + public void NormalizesToNFC() + { + var result = PathNormalizer.NormalizeToNFC("/\u0041\u030A"); + Assert.True(result.IsNormalized(NormalizationForm.FormC)); + Assert.Equal("/\u00C5", result); + } + } +} diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/ServerAddressFacts.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/ServerAddressFacts.cs index 9738dac67..c29a1fdb1 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/ServerAddressFacts.cs +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/ServerAddressFacts.cs @@ -1,3 +1,8 @@ +// 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.Text; using Microsoft.AspNetCore.Server.Kestrel; using Xunit; @@ -43,5 +48,14 @@ public void UrlsAreParsedCorrectly(string url, string scheme, string host, int p Assert.Equal(port, serverAddress.Port); Assert.Equal(pathBase, serverAddress.PathBase); } + + [Fact] + public void PathBaseIsNormalized() + { + var serverAddres = ServerAddress.FromUrl("http://localhost:8080/p\u0041\u030Athbase"); + + Assert.True(serverAddres.PathBase.IsNormalized(NormalizationForm.FormC)); + Assert.Equal("/p\u00C5thbase", serverAddres.PathBase); + } } } diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/project.json b/test/Microsoft.AspNetCore.Server.KestrelTests/project.json index e788721ed..3abc3b112 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/project.json +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/project.json @@ -12,6 +12,8 @@ "dnxcore50": { "dependencies": { "System.Diagnostics.TraceSource": "4.0.0-*", + "System.Globalization.Extensions": "4.0.1-*", + "System.IO": "4.1.0-*", "System.Net.Http.WinHttpHandler": "4.0.0-*", "System.Net.Sockets": "4.1.0-*", "System.Runtime.Handles": "4.0.1-*"