diff --git a/src/Servers/HttpSys/src/Microsoft.AspNetCore.Server.HttpSys.csproj b/src/Servers/HttpSys/src/Microsoft.AspNetCore.Server.HttpSys.csproj index 0b1b563e2f6b..50ed94c1a45d 100644 --- a/src/Servers/HttpSys/src/Microsoft.AspNetCore.Server.HttpSys.csproj +++ b/src/Servers/HttpSys/src/Microsoft.AspNetCore.Server.HttpSys.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Servers/IIS/IIS/src/Microsoft.AspNetCore.Server.IIS.csproj b/src/Servers/IIS/IIS/src/Microsoft.AspNetCore.Server.IIS.csproj index 5333537bd8f3..a3d525a577e9 100644 --- a/src/Servers/IIS/IIS/src/Microsoft.AspNetCore.Server.IIS.csproj +++ b/src/Servers/IIS/IIS/src/Microsoft.AspNetCore.Server.IIS.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs index 58e040c878f3..7f4be5163720 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http/Http1Connection.cs @@ -416,7 +416,7 @@ private void ParseTarget(TargetOffsetPathLength targetPath, Span target) else { var path = target[..pathLength]; - Path = _parsedPath = PathNormalizer.DecodePath(path, targetPath.IsEncoded, RawTarget, queryLength); + Path = _parsedPath = PathDecoder.DecodePath(path, targetPath.IsEncoded, RawTarget, queryLength); } } catch (InvalidOperationException) diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/PathDecoder.cs b/src/Servers/Kestrel/Core/src/Internal/Http/PathDecoder.cs new file mode 100644 index 000000000000..68fc9351fb84 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Http/PathDecoder.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.AspNetCore.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; + +internal static class PathDecoder +{ + public static string DecodePath(Span path, bool pathEncoded, string rawTarget, int queryLength) + { + int pathLength; + if (pathEncoded) + { + // URI was encoded, unescape and then parse as UTF-8 + pathLength = UrlDecoder.DecodeInPlace(path, isFormEncoding: false); + + // Removing dot segments must be done after unescaping. From RFC 3986: + // + // URI producing applications should percent-encode data octets that + // correspond to characters in the reserved set unless these characters + // are specifically allowed by the URI scheme to represent data in that + // component. If a reserved character is found in a URI component and + // no delimiting role is known for that character, then it must be + // interpreted as representing the data octet corresponding to that + // character's encoding in US-ASCII. + // + // https://tools.ietf.org/html/rfc3986#section-2.2 + pathLength = PathNormalizer.RemoveDotSegments(path.Slice(0, pathLength)); + + return Encoding.UTF8.GetString(path.Slice(0, pathLength)); + } + + pathLength = PathNormalizer.RemoveDotSegments(path); + + if (path.Length == pathLength && queryLength == 0) + { + // If no decoding was required, no dot segments were removed and + // there is no query, the request path is the same as the raw target + return rawTarget; + } + + return path.Slice(0, pathLength).GetAsciiStringNonNullCharacters(); + } +} diff --git a/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs b/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs deleted file mode 100644 index eef91f142abe..000000000000 --- a/src/Servers/Kestrel/Core/src/Internal/Http/PathNormalizer.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using Microsoft.AspNetCore.Internal; -using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; - -namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; - -internal static class PathNormalizer -{ - private const byte ByteSlash = (byte)'/'; - private const byte ByteDot = (byte)'.'; - - public static string DecodePath(Span path, bool pathEncoded, string rawTarget, int queryLength) - { - int pathLength; - if (pathEncoded) - { - // URI was encoded, unescape and then parse as UTF-8 - pathLength = UrlDecoder.DecodeInPlace(path, isFormEncoding: false); - - // Removing dot segments must be done after unescaping. From RFC 3986: - // - // URI producing applications should percent-encode data octets that - // correspond to characters in the reserved set unless these characters - // are specifically allowed by the URI scheme to represent data in that - // component. If a reserved character is found in a URI component and - // no delimiting role is known for that character, then it must be - // interpreted as representing the data octet corresponding to that - // character's encoding in US-ASCII. - // - // https://tools.ietf.org/html/rfc3986#section-2.2 - pathLength = RemoveDotSegments(path.Slice(0, pathLength)); - - return Encoding.UTF8.GetString(path.Slice(0, pathLength)); - } - - pathLength = RemoveDotSegments(path); - - if (path.Length == pathLength && queryLength == 0) - { - // If no decoding was required, no dot segments were removed and - // there is no query, the request path is the same as the raw target - return rawTarget; - } - - return path.Slice(0, pathLength).GetAsciiStringNonNullCharacters(); - } - - // In-place implementation of the algorithm from https://tools.ietf.org/html/rfc3986#section-5.2.4 - public static int RemoveDotSegments(Span src) - { - Debug.Assert(src[0] == '/', "Path segment must always start with a '/'"); - ReadOnlySpan dotSlash = "./"u8; - ReadOnlySpan slashDot = "/."u8; - - var writtenLength = 0; - var readPointer = 0; - - while (src.Length > readPointer) - { - var currentSrc = src[readPointer..]; - var nextDotSegmentIndex = currentSrc.IndexOf(slashDot); - if (nextDotSegmentIndex < 0 && readPointer == 0) - { - return src.Length; - } - if (nextDotSegmentIndex < 0) - { - // Copy the remianing src to dst, and return. - currentSrc.CopyTo(src[writtenLength..]); - writtenLength += src.Length - readPointer; - return writtenLength; - } - else if (nextDotSegmentIndex > 0) - { - // Copy until the next segment excluding the trailer. - currentSrc[..nextDotSegmentIndex].CopyTo(src[writtenLength..]); - writtenLength += nextDotSegmentIndex; - readPointer += nextDotSegmentIndex; - } - - var remainingLength = src.Length - readPointer; - - // Case of /../ or /./ or non-dot segments. - if (remainingLength > 3) - { - var nextIndex = readPointer + 2; - - if (src[nextIndex] == ByteSlash) - { - // Case: /./ - readPointer = nextIndex; - } - else if (MemoryMarshal.CreateSpan(ref src[nextIndex], 2).StartsWith(dotSlash)) - { - // Case: /../ - // Remove the last segment and replace the path with / - var lastIndex = MemoryMarshal.CreateSpan(ref src[0], writtenLength).LastIndexOf(ByteSlash); - - // Move write pointer to the end of the previous segment without / or to start position - writtenLength = int.Max(0, lastIndex); - - // Move the read pointer to the next segments beginning including / - readPointer += 3; - } - else - { - // Not a dot segment e.g. /.a, copy the matched /. and the next character then bump the read pointer - src.Slice(readPointer, 3).CopyTo(src[writtenLength..]); - writtenLength += 3; - readPointer = nextIndex + 1; - } - } - - // Ending with /.. or /./ or non-dot segments. - else if (remainingLength == 3) - { - var nextIndex = readPointer + 2; - if (src[nextIndex] == ByteSlash) - { - // Case: /./ Replace the /./ segment with a closing / - src[writtenLength++] = ByteSlash; - return writtenLength; - } - else if (src[nextIndex] == ByteDot) - { - // Case: /.. Remove the last segment and replace the path with / - var lastSlashIndex = MemoryMarshal.CreateSpan(ref src[0], writtenLength).LastIndexOf(ByteSlash); - - // If this was the beginning of the string, then return / - if (lastSlashIndex < 0) - { - Debug.Assert(src[0] == '/'); - return 1; - } - else - { - writtenLength = lastSlashIndex + 1; - } - return writtenLength; - } - else - { - // Not a dot segment e.g. /.a, copy the remaining part. - src[readPointer..].CopyTo(src[writtenLength..]); - return writtenLength + 3; - } - } - // Ending with /. - else if (remainingLength == 2) - { - src[writtenLength++] = ByteSlash; - return writtenLength; - } - } - return writtenLength; - } -} diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs index 5ee7fc967785..aed172b23eba 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Stream.cs @@ -435,7 +435,7 @@ private bool TryValidatePath(ReadOnlySpan pathSegment) pathBuffer[i] = (byte)ch; } - Path = PathNormalizer.DecodePath(pathBuffer, pathEncoded, RawTarget!, QueryString!.Length); + Path = PathDecoder.DecodePath(pathBuffer, pathEncoded, RawTarget!, QueryString!.Length); return true; } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs index 24afce638c3b..ccf595d7f89f 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http3/Http3Stream.cs @@ -1166,7 +1166,7 @@ private bool TryValidatePath(ReadOnlySpan pathSegment) pathBuffer[i] = (byte)ch; } - Path = PathNormalizer.DecodePath(pathBuffer, pathEncoded, RawTarget!, QueryString!.Length); + Path = PathDecoder.DecodePath(pathBuffer, pathEncoded, RawTarget!, QueryString!.Length); return true; } diff --git a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj index 6c0bf1237af4..d53a2a61a10c 100644 --- a/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj +++ b/src/Servers/Kestrel/Core/src/Microsoft.AspNetCore.Server.Kestrel.Core.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Servers/Kestrel/Core/test/PathNormalizerTests.cs b/src/Servers/Kestrel/Core/test/PathNormalizerTests.cs deleted file mode 100644 index 2c58a68b8db7..000000000000 --- a/src/Servers/Kestrel/Core/test/PathNormalizerTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Text; -using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; -using Xunit; - -namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests; - -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/")] - [InlineData("/././", "/")] - [InlineData("/./.", "/")] - [InlineData("/../..", "/")] - [InlineData("/../../", "/")] - [InlineData("/../.", "/")] - [InlineData("/./..", "/")] - [InlineData("/.././", "/")] - [InlineData("/./../", "/")] - [InlineData("/..", "/")] - [InlineData("/.", "/")] - [InlineData("/a/abc/../abc/../b", "/a/b")] - [InlineData("/a/abc/.a", "/a/abc/.a")] - [InlineData("/a/abc/..a", "/a/abc/..a")] - [InlineData("/a/.b/c", "/a/.b/c")] - [InlineData("/a/.b/../c", "/a/c")] - [InlineData("/a/../.b/./c", "/.b/c")] - [InlineData("/a/.b/./c", "/a/.b/c")] - [InlineData("/a/./.b/./c", "/a/.b/c")] - [InlineData("/a/..b/c", "/a/..b/c")] - [InlineData("/a/..b/../c", "/a/c")] - [InlineData("/a/../..b/./c", "/..b/c")] - [InlineData("/a/..b/./c", "/a/..b/c")] - [InlineData("/a/./..b/./c", "/a/..b/c")] - 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)); - } -} diff --git a/src/Servers/Kestrel/perf/Microbenchmarks/DotSegmentRemovalBenchmark.cs b/src/Servers/Kestrel/perf/Microbenchmarks/DotSegmentRemovalBenchmark.cs index 05e9a2b0fcc0..cd379215f895 100644 --- a/src/Servers/Kestrel/perf/Microbenchmarks/DotSegmentRemovalBenchmark.cs +++ b/src/Servers/Kestrel/perf/Microbenchmarks/DotSegmentRemovalBenchmark.cs @@ -3,7 +3,7 @@ using System.Text; using BenchmarkDotNet.Attributes; -using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; +using Microsoft.AspNetCore.Internal; namespace Microsoft.AspNetCore.Server.Kestrel.Microbenchmarks; diff --git a/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs b/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs index 27ed6bcdf1b4..1b86b852f67b 100644 --- a/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs +++ b/src/Shared/HttpSys/RequestProcessing/RequestUriBuilder.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Text; +using Microsoft.AspNetCore.Internal; namespace Microsoft.AspNetCore.HttpSys.Internal; diff --git a/src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs b/src/Shared/PathNormalizer/PathNormalizer.cs similarity index 98% rename from src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs rename to src/Shared/PathNormalizer/PathNormalizer.cs index bc26d3bd4608..9d79afea2af0 100644 --- a/src/Shared/HttpSys/RequestProcessing/PathNormalizer.cs +++ b/src/Shared/PathNormalizer/PathNormalizer.cs @@ -4,7 +4,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; -namespace Microsoft.AspNetCore.HttpSys.Internal; +namespace Microsoft.AspNetCore.Internal; internal static class PathNormalizer { 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 95e769782841..f72f75419dd0 100644 --- a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj +++ b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/src/Shared/test/Shared.Tests/PathNormalizerTests.cs b/src/Shared/test/Shared.Tests/PathNormalizerTests.cs index c45e507f937c..982c5fd96eaa 100644 --- a/src/Shared/test/Shared.Tests/PathNormalizerTests.cs +++ b/src/Shared/test/Shared.Tests/PathNormalizerTests.cs @@ -4,8 +4,9 @@ using System; using System.Text; using Xunit; +using Microsoft.AspNetCore.Internal; -namespace Microsoft.AspNetCore.HttpSys.Internal; +namespace Microsoft.AspNetCore.Internal.Tests; public class PathNormalizerTests { @@ -53,6 +54,29 @@ public class PathNormalizerTests [InlineData("/", "/")] [InlineData("/no/segments", "/no/segments")] [InlineData("/no/segments/", "/no/segments/")] + [InlineData("/././", "/")] + [InlineData("/./.", "/")] + [InlineData("/../..", "/")] + [InlineData("/../../", "/")] + [InlineData("/../.", "/")] + [InlineData("/./..", "/")] + [InlineData("/.././", "/")] + [InlineData("/./../", "/")] + [InlineData("/..", "/")] + [InlineData("/.", "/")] + [InlineData("/a/abc/../abc/../b", "/a/b")] + [InlineData("/a/abc/.a", "/a/abc/.a")] + [InlineData("/a/abc/..a", "/a/abc/..a")] + [InlineData("/a/.b/c", "/a/.b/c")] + [InlineData("/a/.b/../c", "/a/c")] + [InlineData("/a/../.b/./c", "/.b/c")] + [InlineData("/a/.b/./c", "/a/.b/c")] + [InlineData("/a/./.b/./c", "/a/.b/c")] + [InlineData("/a/..b/c", "/a/..b/c")] + [InlineData("/a/..b/../c", "/a/c")] + [InlineData("/a/../..b/./c", "/..b/c")] + [InlineData("/a/..b/./c", "/a/..b/c")] + [InlineData("/a/./..b/./c", "/a/..b/c")] public void RemovesDotSegments(string input, string expected) { var data = Encoding.ASCII.GetBytes(input);