diff --git a/eng/PatchConfig.props b/eng/PatchConfig.props index 57bc9bf38d..47f840db70 100644 --- a/eng/PatchConfig.props +++ b/eng/PatchConfig.props @@ -37,6 +37,7 @@ Later on, this will be checked using this condition: + Microsoft.AspNetCore.Server.HttpSys; diff --git a/src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs b/src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs index 837c53340a..f987ffe8b1 100644 --- a/src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs +++ b/src/Servers/HttpSys/test/FunctionalTests/RequestTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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; @@ -338,6 +338,134 @@ namespace Microsoft.AspNetCore.Server.HttpSys } } + [ConditionalFact] + public async Task Request_UrlUnescaping() + { + // Must start with '/' + var stringBuilder = new StringBuilder("/"); + for (var i = 32; i < 127; i++) + { + stringBuilder.Append("%"); + stringBuilder.Append(i.ToString("X2")); + } + var rawPath = stringBuilder.ToString(); + string root; + using (var server = Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + var requestInfo = httpContext.Features.Get(); + Assert.Equal(rawPath, requestInfo.RawTarget); + // '/' %2F is an exception, un-escaping it would change the structure of the path + Assert.Equal("/ !\"#$%&'()*+,-.%2F0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", requestInfo.Path); + return Task.FromResult(0); + })) + { + var response = await SendSocketRequestAsync(root, rawPath); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.Equal("200", responseStatusCode); + } + } + + [ConditionalFact] + public async Task Request_WithDoubleSlashes_LeftAlone() + { + var rawPath = "//a/b//c"; + string root; + using (var server = Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + var requestInfo = httpContext.Features.Get(); + Assert.Equal(rawPath, requestInfo.RawTarget); + Assert.Equal(rawPath, requestInfo.Path); + return Task.FromResult(0); + })) + { + var response = await SendSocketRequestAsync(root, rawPath); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.Equal("200", responseStatusCode); + } + } + + [ConditionalTheory] + [InlineData("/", "/a/b/../c", "", "/a/c")] + [InlineData("/", "/a/b/./c", "", "/a/b/c")] + [InlineData("/a", "/a/./c", "/a", "/c")] + [InlineData("/a", "/a/d/../b/c", "/a", "/b/c")] + [InlineData("/a/b", "/a/d/../b/c", "/a/b", "/c")] // Http.Sys uses the cooked URL when routing. + [InlineData("/a/b", "/a/./b/c", "/a/b", "/c")] // Http.Sys uses the cooked URL when routing. + public async Task Request_WithNavigation_Removed(string basePath, string input, string expectedPathBase, string expectedPath) + { + string root; + using (var server = Utilities.CreateHttpServerReturnRoot(basePath, out root, httpContext => + { + var requestInfo = httpContext.Features.Get(); + Assert.Equal(input, requestInfo.RawTarget); + Assert.Equal(expectedPathBase, requestInfo.PathBase); + Assert.Equal(expectedPath, requestInfo.Path); + return Task.FromResult(0); + })) + { + var response = await SendSocketRequestAsync(root, input); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.Equal("200", responseStatusCode); + } + } + + [ConditionalTheory] + [InlineData("/a/b/%2E%2E/c", "/a/c")] + [InlineData("/a/b/%2E/c", "/a/b/c")] + public async Task Request_WithEscapedNavigation_Removed(string input, string expected) + { + string root; + using (var server = Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + var requestInfo = httpContext.Features.Get(); + Assert.Equal(input, requestInfo.RawTarget); + Assert.Equal(expected, requestInfo.Path); + return Task.FromResult(0); + })) + { + var response = await SendSocketRequestAsync(root, input); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.Equal("200", responseStatusCode); + } + } + + [ConditionalFact] + public async Task Request_ControlCharacters_400() + { + string root; + using (var server = Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + throw new NotImplementedException(); + })) + { + for (var i = 0; i < 32; i++) + { + if (i == 9 || i == 10) continue; // \t and \r are allowed by Http.Sys. + var response = await SendSocketRequestAsync(root, "/" + (char)i); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.True(string.Equals("400", responseStatusCode), i.ToString("X2")); + } + } + } + + [ConditionalFact] + public async Task Request_EscapedControlCharacters_400() + { + string root; + using (var server = Utilities.CreateHttpServerReturnRoot("/", out root, httpContext => + { + throw new NotImplementedException(); + })) + { + for (var i = 0; i < 32; i++) + { + var response = await SendSocketRequestAsync(root, "/%" + i.ToString("X2")); + var responseStatusCode = response.Substring(9); // Skip "HTTP/1.1 " + Assert.True(string.Equals("400", responseStatusCode), i.ToString("X2")); + } + } + } + private IServer CreateServer(out string root, RequestDelegate app) { // TODO: We're just doing this to get a dynamic port. This can be removed later when we add support for hot-adding prefixes. diff --git a/src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs b/src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs new file mode 100644 index 0000000000..f6a0159ff5 --- /dev/null +++ b/src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs @@ -0,0 +1,208 @@ +// 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.Diagnostics; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.HttpSys.Internal +{ + internal static class PathNormalizer + { + private const byte ByteSlash = (byte)'/'; + private const byte ByteDot = (byte)'.'; + + // In-place implementation of the algorithm from https://tools.ietf.org/html/rfc3986#section-5.2.4 + public static unsafe int RemoveDotSegments(Span input) + { + fixed (byte* start = &MemoryMarshal.GetReference(input)) + { + var end = start + input.Length; + return RemoveDotSegments(start, end); + } + } + + public static unsafe int RemoveDotSegments(byte* start, byte* end) + { + if (!ContainsDotSegments(start, end)) + { + return (int)(end - start); + } + + var src = start; + var dst = start; + + while (src < end) + { + var ch1 = *src; + Debug.Assert(ch1 == '/', "Path segment must always start with a '/'"); + + byte ch2, ch3, ch4; + + switch (end - src) + { + case 1: + break; + case 2: + ch2 = *(src + 1); + + if (ch2 == ByteDot) + { + // B. if the input buffer begins with a prefix of "/./" or "/.", + // where "." is a complete path segment, then replace that + // prefix with "/" in the input buffer; otherwise, + src += 1; + *src = ByteSlash; + continue; + } + + break; + case 3: + ch2 = *(src + 1); + ch3 = *(src + 2); + + if (ch2 == ByteDot && ch3 == ByteDot) + { + // C. if the input buffer begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input buffer and remove the last + // segment and its preceding "/" (if any) from the output + // buffer; otherwise, + src += 2; + *src = ByteSlash; + + if (dst > start) + { + do + { + dst--; + } while (dst > start && *dst != ByteSlash); + } + + continue; + } + else if (ch2 == ByteDot && ch3 == ByteSlash) + { + // B. if the input buffer begins with a prefix of "/./" or "/.", + // where "." is a complete path segment, then replace that + // prefix with "/" in the input buffer; otherwise, + src += 2; + continue; + } + + break; + default: + ch2 = *(src + 1); + ch3 = *(src + 2); + ch4 = *(src + 3); + + if (ch2 == ByteDot && ch3 == ByteDot && ch4 == ByteSlash) + { + // C. if the input buffer begins with a prefix of "/../" or "/..", + // where ".." is a complete path segment, then replace that + // prefix with "/" in the input buffer and remove the last + // segment and its preceding "/" (if any) from the output + // buffer; otherwise, + src += 3; + + if (dst > start) + { + do + { + dst--; + } while (dst > start && *dst != ByteSlash); + } + + continue; + } + else if (ch2 == ByteDot && ch3 == ByteSlash) + { + // B. if the input buffer begins with a prefix of "/./" or "/.", + // where "." is a complete path segment, then replace that + // prefix with "/" in the input buffer; otherwise, + src += 2; + continue; + } + + break; + } + + // E. move the first path segment in the input buffer to the end of + // the output buffer, including the initial "/" character (if + // any) and any subsequent characters up to, but not including, + // the next "/" character or the end of the input buffer. + do + { + *dst++ = ch1; + ch1 = *++src; + } while (src < end && ch1 != ByteSlash); + } + + if (dst == start) + { + *dst++ = ByteSlash; + } + + return (int)(dst - start); + } + + public static unsafe bool ContainsDotSegments(byte* start, byte* end) + { + var src = start; + var dst = start; + + while (src < end) + { + var ch1 = *src; + Debug.Assert(ch1 == '/', "Path segment must always start with a '/'"); + + byte ch2, ch3, ch4; + + switch (end - src) + { + case 1: + break; + case 2: + ch2 = *(src + 1); + + if (ch2 == ByteDot) + { + return true; + } + + break; + case 3: + ch2 = *(src + 1); + ch3 = *(src + 2); + + if ((ch2 == ByteDot && ch3 == ByteDot) || + (ch2 == ByteDot && ch3 == ByteSlash)) + { + return true; + } + + break; + default: + ch2 = *(src + 1); + ch3 = *(src + 2); + ch4 = *(src + 3); + + if ((ch2 == ByteDot && ch3 == ByteDot && ch4 == ByteSlash) || + (ch2 == ByteDot && ch3 == ByteSlash)) + { + return true; + } + + break; + } + + do + { + ch1 = *++src; + } while (src < end && ch1 != ByteSlash); + } + + return false; + } + } +} diff --git a/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs b/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs index 6308d6d8ea..fbc1101436 100644 --- a/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs +++ b/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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; @@ -33,7 +33,9 @@ namespace Microsoft.AspNetCore.HttpSys.Internal var unescapedPath = Unescape(rawPath); - return UTF8.GetString(unescapedPath.Array, unescapedPath.Offset, unescapedPath.Count); + var length = PathNormalizer.RemoveDotSegments(unescapedPath); + + return UTF8.GetString(unescapedPath.Array, unescapedPath.Offset, length); } /// diff --git a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj index ba8919bf5e..4b65f742a9 100644 --- a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj +++ b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj @@ -1,13 +1,15 @@ - + netcoreapp2.1;net461 portable + true + @@ -19,8 +21,11 @@ + + + diff --git a/src/Shared/test/Shared.Tests/PathNormalizerTests.cs b/src/Shared/test/Shared.Tests/PathNormalizerTests.cs new file mode 100644 index 0000000000..7d9425a864 --- /dev/null +++ b/src/Shared/test/Shared.Tests/PathNormalizerTests.cs @@ -0,0 +1,64 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.HttpSys.Internal +{ + 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("/a/../b", "/b")] + [InlineData("/a/./", "/a/")] + [InlineData("/a/.", "/a/")] + [InlineData("/a/../", "/")] + [InlineData("/a/..", "/")] + [InlineData("/a/../b/../", "/")] + [InlineData("/a/../b/..", "/")] + [InlineData("/a/../../b", "/b")] + [InlineData("/a/../../b/", "/b/")] + [InlineData("/a/.././../b", "/b")] + [InlineData("/a/.././../b/", "/b/")] + [InlineData("/a/b/c/./../../d", "/a/d")] + [InlineData("/./a/b/c/./../../d", "/a/d")] + [InlineData("/../a/b/c/./../../d", "/a/d")] + [InlineData("/./../a/b/c/./../../d", "/a/d")] + [InlineData("/.././a/b/c/./../../d", "/a/d")] + [InlineData("/.a", "/.a")] + [InlineData("/..a", "/..a")] + [InlineData("/...", "/...")] + [InlineData("/a/.../b", "/a/.../b")] + [InlineData("/a/../.../../b", "/b")] + [InlineData("/a/.b", "/a/.b")] + [InlineData("/a/..b", "/a/..b")] + [InlineData("/a/b.", "/a/b.")] + [InlineData("/a/b..", "/a/b..")] + [InlineData("/longlong/../short", "/short")] + [InlineData("/short/../longlong", "/longlong")] + [InlineData("/longlong/../short/..", "/")] + [InlineData("/short/../longlong/..", "/")] + [InlineData("/longlong/../short/../", "/")] + [InlineData("/short/../longlong/../", "/")] + [InlineData("/", "/")] + [InlineData("/no/segments", "/no/segments")] + [InlineData("/no/segments/", "/no/segments/")] + public void RemovesDotSegments(string input, string expected) + { + var data = Encoding.ASCII.GetBytes(input); + var length = PathNormalizer.RemoveDotSegments(new Span(data)); + Assert.True(length >= 1); + Assert.Equal(expected, Encoding.ASCII.GetString(data, 0, length)); + } + } +}