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