Browse Source

Route parameter with urlencoded slash inconsistency between TestServer and Kestrel behavior (#33364)

ladeak 4 years ago
parent
commit
87b761dbbb

+ 23 - 0
src/Hosting/TestHost/test/ClientHandlerTests.cs

@@ -24,6 +24,29 @@ namespace Microsoft.AspNetCore.TestHost
 {
     public class ClientHandlerTests
     {
+        [Fact]
+        public async Task SlashUrlEncodedDoesNotGetDecoded()
+        {
+            var handler = new ClientHandler(new PathString(), new InspectingApplication(features =>
+            {
+                Assert.True(features.Get<IHttpRequestLifetimeFeature>().RequestAborted.CanBeCanceled);
+                Assert.Equal(HttpProtocol.Http11, features.Get<IHttpRequestFeature>().Protocol);
+                Assert.Equal("GET", features.Get<IHttpRequestFeature>().Method);
+                Assert.Equal("https", features.Get<IHttpRequestFeature>().Scheme);
+                Assert.Equal("/api/a%2Fb c", features.Get<IHttpRequestFeature>().Path);
+                Assert.NotNull(features.Get<IHttpRequestFeature>().Body);
+                Assert.NotNull(features.Get<IHttpRequestFeature>().Headers);
+                Assert.NotNull(features.Get<IHttpResponseFeature>().Headers);
+                Assert.NotNull(features.Get<IHttpResponseBodyFeature>().Stream);
+                Assert.Equal(200, features.Get<IHttpResponseFeature>().StatusCode);
+                Assert.Null(features.Get<IHttpResponseFeature>().ReasonPhrase);
+                Assert.Equal("example.com", features.Get<IHttpRequestFeature>().Headers["host"]);
+                Assert.NotNull(features.Get<IHttpRequestLifetimeFeature>());
+            }));
+            var httpClient = new HttpClient(handler);
+            await httpClient.GetAsync("https://example.com/api/a%2Fb%20c");
+        }
+
         [Fact]
         public Task ExpectedKeysAreAvailable()
         {

+ 33 - 0
src/Http/Http.Abstractions/perf/Microbenchmarks/PathStringBenchmark.cs

@@ -0,0 +1,33 @@
+using System;
+using System.Collections.Generic;
+using BenchmarkDotNet.Attributes;
+
+namespace Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks
+{
+    public class PathStringBenchmark
+    {
+        private const string TestPath = "/api/a%2Fb/c";
+        private const string LongTestPath = "/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b";
+        private const string LongTestPathEarlyPercent = "/t%20hisMustBeAVeryLongPath/SoLongButStillShorterToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeap/api/a%20b";
+
+        public IEnumerable<object> TestPaths => new[] { TestPath, LongTestPath, LongTestPathEarlyPercent };
+
+        public IEnumerable<object> TestUris => new[] { new Uri($"https://localhost:5001/{TestPath}"), new Uri($"https://localhost:5001/{LongTestPath}"), new Uri($"https://localhost:5001/{LongTestPathEarlyPercent}") };
+
+        [Benchmark]
+        [ArgumentsSource(nameof(TestPaths))]
+        public string OnPathFromUriComponent(string testPath)
+        {
+            var pathString = PathString.FromUriComponent(testPath);
+            return pathString.Value;
+        }
+
+        [Benchmark]
+        [ArgumentsSource(nameof(TestUris))]
+        public string OnUriFromUriComponent(Uri testUri)
+        {
+            var pathString = PathString.FromUriComponent(testUri);
+            return pathString.Value;
+        }
+    }
+}

+ 1 - 0
src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj

@@ -24,6 +24,7 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
     <Compile Include="$(SharedSourceRoot)ActivatorUtilities\*.cs" />
     <Compile Include="$(SharedSourceRoot)ParameterDefaultValue\*.cs" />
     <Compile Include="$(SharedSourceRoot)PropertyHelper\**\*.cs" />
+    <Compile Include="$(SharedSourceRoot)\UrlDecoder\UrlDecoder.cs" Link="UrlDecoder.cs" />
   </ItemGroup>
 
   <ItemGroup>

+ 19 - 5
src/Http/Http.Abstractions/src/PathString.cs

@@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis;
 using System.Globalization;
 using System.Text;
 using Microsoft.AspNetCore.Http.Abstractions;
+using Microsoft.AspNetCore.Internal;
 
 namespace Microsoft.AspNetCore.Http
 {
@@ -16,6 +17,8 @@ namespace Microsoft.AspNetCore.Http
     [TypeConverter(typeof(PathStringConverter))]
     public readonly struct PathString : IEquatable<PathString>
     {
+        internal const int StackAllocThreshold = 128;
+
         /// <summary>
         /// Represents the empty path. This field is read-only.
         /// </summary>
@@ -172,8 +175,16 @@ namespace Microsoft.AspNetCore.Http
         /// <returns>The resulting PathString</returns>
         public static PathString FromUriComponent(string uriComponent)
         {
-            // REVIEW: what is the exactly correct thing to do?
-            return new PathString(Uri.UnescapeDataString(uriComponent));
+            int position = uriComponent.IndexOf('%');
+            if (position == -1)
+            {
+                return new PathString(uriComponent);
+            }
+            Span<char> pathBuffer = uriComponent.Length <= StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length];
+            uriComponent.CopyTo(pathBuffer);
+            var length = UrlDecoder.DecodeInPlace(pathBuffer.Slice(position, uriComponent.Length - position));
+            pathBuffer = pathBuffer.Slice(0, position + length);
+            return new PathString(pathBuffer.ToString());
         }
 
         /// <summary>
@@ -187,9 +198,12 @@ namespace Microsoft.AspNetCore.Http
             {
                 throw new ArgumentNullException(nameof(uri));
             }
-
-            // REVIEW: what is the exactly correct thing to do?
-            return new PathString("/" + uri.GetComponents(UriComponents.Path, UriFormat.Unescaped));
+            var uriComponent = uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped);
+            Span<char> pathBuffer = uriComponent.Length < StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length + 1];
+            pathBuffer[0] = '/';
+            var length = UrlDecoder.DecodeRequestLine(uriComponent.AsSpan(), pathBuffer.Slice(1));
+            pathBuffer = pathBuffer.Slice(0, length + 1);
+            return new PathString(pathBuffer.ToString());
         }
 
         /// <summary>

+ 112 - 0
src/Http/Http.Abstractions/test/PathStringTests.cs

@@ -2,7 +2,10 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Collections.Generic;
 using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
 using Microsoft.AspNetCore.Testing;
 using Xunit;
 
@@ -236,5 +239,114 @@ namespace Microsoft.AspNetCore.Http
             PathString p2 = s1;
             Assert.Equal(p1, p2);
         }
+
+        [Theory]
+        [InlineData("/a%2Fb")]
+        [InlineData("/a%2F")]
+        [InlineData("/%2fb")]
+        [InlineData("/a%2Fb/c%2Fd/e")]
+        public void StringFromUriComponentLeavesForwardSlashEscaped(string input)
+        {
+            var sut = PathString.FromUriComponent(input);
+            Assert.Equal(input, sut.Value);
+        }
+
+        [Theory]
+        [InlineData("/a%2Fb")]
+        [InlineData("/a%2F")]
+        [InlineData("/%2fb")]
+        [InlineData("/a%2Fb/c%2Fd/e")]
+        public void UriFromUriComponentLeavesForwardSlashEscaped(string input)
+        {
+            var uri = new Uri($"https://localhost:5001{input}");
+            var sut = PathString.FromUriComponent(uri);
+            Assert.Equal(input, sut.Value);
+        }
+
+        [Theory]
+        [InlineData("/a%20b", "/a b")]
+        [InlineData("/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b",
+            "/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a b")]
+        public void StringFromUriComponentUnescapes(string input, string expected)
+        {
+            var sut = PathString.FromUriComponent(input);
+            Assert.Equal(expected, sut.Value);
+        }
+
+        [Theory]
+        [InlineData("/a%20b", "/a b")]
+        [InlineData("/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b",
+    "/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a b")]
+        public void UriFromUriComponentUnescapes(string input, string expected)
+        {
+            var uri = new Uri($"https://localhost:5001{input}");
+            var sut = PathString.FromUriComponent(uri);
+            Assert.Equal(expected, sut.Value);
+        }
+
+        [Theory]
+        [InlineData("/a%2Fb")]
+        [InlineData("/a%2F")]
+        [InlineData("/%2fb")]
+        [InlineData("/%2Fb%20c")]
+        [InlineData("/a%2Fb%20c")]
+        [InlineData("/a%20b")]
+        [InlineData("/a%2Fb/c%2Fd/e%20f")]
+        [InlineData("/%E4%BD%A0%E5%A5%BD")]
+        public void FromUriComponentToUriComponent(string input)
+        {
+            var sut = PathString.FromUriComponent(input);
+            Assert.Equal(input, sut.ToUriComponent());
+        }
+
+        [Theory]
+        [MemberData(nameof(CharsToUnescape))]
+        [InlineData("/%E4%BD%A0%E5%A5%BD", "/你好")]
+        public void FromUriComponentUnescapesAllExceptForwardSlash(string input, string expected)
+        {
+            var sut = PathString.FromUriComponent(input);
+            Assert.Equal(expected, sut.Value);
+        }
+
+        [Theory]
+        [InlineData(-1)]
+        [InlineData(0)]
+        [InlineData(1)]
+        public void ExercisingStringFromUriComponentOnStackAllocLimit(int offset)
+        {
+            var path = "/";
+            var testString = new string('a', PathString.StackAllocThreshold + offset - path.Length);
+            var sut = PathString.FromUriComponent(path + testString);
+            Assert.Equal(PathString.StackAllocThreshold + offset, sut.Value!.Length);
+        }
+
+        [Theory]
+        [InlineData(-1)]
+        [InlineData(0)]
+        [InlineData(1)]
+        public void ExercisingUriFromUriComponentOnStackAllocLimit(int offset)
+        {
+            var localhost = "https://localhost:5001/";
+            var testString = new string('a', PathString.StackAllocThreshold + offset);
+            var sut = PathString.FromUriComponent(new Uri(localhost + testString));
+            Assert.Equal(PathString.StackAllocThreshold + offset + 1, sut.Value!.Length);
+        }
+
+        public static IEnumerable<object[]> CharsToUnescape
+        {
+            get
+            {
+                foreach (var item in Enumerable.Range(1, 127))
+                {
+                    // %2F is '/' not escaped for paths
+                    if (item != 0x2f)
+                    {
+                        var hexEscapedValue = "%" + item.ToString("x2", CultureInfo.InvariantCulture);
+                        var expected = Uri.UnescapeDataString(hexEscapedValue);
+                        yield return new object[] { "/a" + hexEscapedValue, "/a" + expected };
+                    }
+                }
+            }
+        }
     }
 }

+ 1 - 1
src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs

@@ -61,7 +61,7 @@ namespace RoutingWebSite
                 var results = new RouteValueDictionary();
                 foreach (var kvp in kvps)
                 {
-                    var split = kvp.Split("=");
+                    var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
                     results[split[0]] = split[1];
                 }
 

+ 2 - 1
src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamicAndRazorPages.cs

@@ -1,6 +1,7 @@
 // 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.Threading.Tasks;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Http;
@@ -50,7 +51,7 @@ namespace RoutingWebSite
                 var results = new RouteValueDictionary();
                 foreach (var kvp in kvps)
                 {
-                    var split = kvp.Split("=");
+                    var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
                     results[split[0]] = split[1];
                 }
 

+ 1 - 1
src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamicOrder.cs

@@ -115,7 +115,7 @@ namespace RoutingWebSite
 
                 foreach (var kvp in kvps)
                 {
-                    var split = kvp.Split("=");
+                    var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
                     if (split.Length == 2)
                     {
                         results[split[0]] = split[1];

+ 284 - 6
src/Shared/UrlDecoder/UrlDecoder.cs

@@ -3,6 +3,7 @@
 // See the LICENSE file in the project root for more information.
 
 using System;
+using System.Runtime.CompilerServices;
 
 namespace Microsoft.AspNetCore.Internal
 {
@@ -20,13 +21,13 @@ namespace Microsoft.AspNetCore.Internal
             if (destination.Length < source.Length)
             {
                 throw new ArgumentException(
-                    "Length of the destination byte span is less then the source.",
+                    "Length of the destination byte span is less than the source.",
                     nameof(destination));
             }
 
             // This requires the destination span to be larger or equal to source span
             source.CopyTo(destination);
-            return DecodeInPlace(destination, isFormEncoding);
+            return DecodeInPlace(destination.Slice(0, source.Length), isFormEncoding);
         }
 
         /// <summary>
@@ -231,7 +232,7 @@ namespace Microsoft.AspNetCore.Internal
             return true;
         }
 
-        private static void Copy(int begin, int end, ref int writer, Span<byte> buffer)
+        private static void Copy<T>(int begin, int end, ref int writer, Span<T> buffer)
         {
             while (begin != end)
             {
@@ -289,7 +290,6 @@ namespace Microsoft.AspNetCore.Internal
             return (value1 << 4) + value2;
         }
 
-
         /// <summary>
         /// Read the next char and convert it into hexadecimal value.
         ///
@@ -307,11 +307,11 @@ namespace Microsoft.AspNetCore.Internal
             }
 
             var value = buffer[scan++];
-            var isHead = ((value >= '0') && (value <= '9')) ||
+            var isHex = ((value >= '0') && (value <= '9')) ||
                          ((value >= 'A') && (value <= 'F')) ||
                          ((value >= 'a') && (value <= 'f'));
 
-            if (!isHead)
+            if (!isHex)
             {
                 return -1;
             }
@@ -345,5 +345,283 @@ namespace Microsoft.AspNetCore.Internal
 
             return false;
         }
+
+        /// <summary>
+        /// Unescape a URL path
+        /// </summary>
+        /// <param name="source">The escape sequences is expected to be well-formed UTF-8 code units.</param>
+        /// <param name="destination">The char span where unescaped url path is copied to.</param>
+        /// <returns>The length of the char sequence of the unescaped url path.</returns>
+        /// <remarks>
+        /// Form Encoding is not supported compared to the <see cref="DecodeRequestLine(ReadOnlySpan{byte}, Span{byte}, bool)" />
+        /// for performance gains, as current use-cases does not require it.
+        /// </remarks>
+        public static int DecodeRequestLine(ReadOnlySpan<char> source, Span<char> destination)
+        {
+            // This requires the destination span to be larger or equal to source span
+            // which is validated by Span<T>.CopyTo.
+            source.CopyTo(destination);
+            return DecodeInPlace(destination.Slice(0, source.Length));
+        }
+
+        /// <summary>
+        /// Unescape a URL path in place.
+        /// </summary>
+        /// <param name="buffer">The escape sequences is expected to be well-formed UTF-8 code units.</param>
+        /// <returns>The number of the chars representing the result.</returns>
+        /// <remarks>
+        /// The unescape is done in place, which means after decoding the result is the subset of
+        /// the input span.
+        /// Form Encoding is not supported compared to the <see cref="DecodeInPlace(Span{byte}, bool)" />
+        /// for performance gains, as current use-cases does not require it.
+        /// </remarks>
+        public static int DecodeInPlace(Span<char> buffer)
+        {
+            // Compared to the byte overload implementation, this is a different
+            // by using the first occurrence of % as the starting position both
+            // for the source and the destination index.
+            int position = buffer.IndexOf('%');
+            if (position == -1)
+            {
+                return buffer.Length;
+            }
+
+            // the slot to read the input
+            var sourceIndex = position;
+
+            // the slot to write the unescaped char
+            var destinationIndex = position;
+
+            while (true)
+            {
+                if (sourceIndex == buffer.Length)
+                {
+                    break;
+                }
+
+                if (buffer[sourceIndex] == '%')
+                {
+                    var decodeIndex = sourceIndex;
+
+                    // If decoding process succeeds, the writer iterator will be moved
+                    // to the next write-ready location. On the other hand if the scanned
+                    // percent-encodings cannot be interpreted as sequence of UTF-8 octets,
+                    // these chars should be copied to output as is.
+                    // The decodeReader iterator is always moved to the first char not yet
+                    // be scanned after the process. A failed decoding means the chars
+                    // between the reader and decodeReader can be copied to output untouched.
+                    if (!DecodeCore(ref decodeIndex, ref destinationIndex, buffer))
+                    {
+                        Copy(sourceIndex, decodeIndex, ref destinationIndex, buffer);
+                    }
+
+                    sourceIndex = decodeIndex;
+                }
+                else
+                {
+                    buffer[destinationIndex++] = buffer[sourceIndex++];
+                }
+            }
+
+            return destinationIndex;
+        }
+
+        /// <summary>
+        /// Unescape the percent-encodings
+        /// </summary>
+        /// <param name="sourceIndex">The iterator point to the first % char</param>
+        /// <param name="destinationIndex">The place to write to</param>
+        /// <param name="buffer">The char array</param>
+        private static bool DecodeCore(ref int sourceIndex, ref int destinationIndex, Span<char> buffer)
+        {
+            // preserves the original head. if the percent-encodings cannot be interpreted as sequence of UTF-8 octets,
+            // chars from this till the last scanned one will be copied to the memory pointed by writer.
+            var codeUnit1 = UnescapePercentEncoding(ref sourceIndex, buffer);
+            if (codeUnit1 == -1)
+            {
+                return false;
+            }
+
+            if (codeUnit1 == 0)
+            {
+                throw new InvalidOperationException("The path contains null characters.");
+            }
+
+            if (codeUnit1 <= 0x7F)
+            {
+                // first code unit < U+007f, it is a single char ASCII
+                buffer[destinationIndex++] = (char)codeUnit1;
+                return true;
+            }
+
+            // anticipate more code units
+            var currentDecodeBits = 0;
+            var codeUnitCount = 1;
+            var expectValueMin = 0;
+            if ((codeUnit1 & 0xE0) == 0xC0)
+            {
+                // 110x xxxx, expect one more code unit
+                currentDecodeBits = codeUnit1 & 0x1F;
+                codeUnitCount = 2;
+                expectValueMin = 0x80;
+            }
+            else if ((codeUnit1 & 0xF0) == 0xE0)
+            {
+                // 1110 xxxx, expect two more code units
+                currentDecodeBits = codeUnit1 & 0x0F;
+                codeUnitCount = 3;
+                expectValueMin = 0x800;
+            }
+            else if ((codeUnit1 & 0xF8) == 0xF0)
+            {
+                // 1111 0xxx, expect three more code units
+                currentDecodeBits = codeUnit1 & 0x07;
+                codeUnitCount = 4;
+                expectValueMin = 0x10000;
+            }
+            else
+            {
+                // invalid first code unit
+                return false;
+            }
+
+            var remainingCodeUnits = codeUnitCount - 1;
+            while (remainingCodeUnits > 0)
+            {
+                // read following three code units
+                if (sourceIndex == buffer.Length)
+                {
+                    return false;
+                }
+
+                var nextSourceIndex = sourceIndex;
+                var nextCodeUnit = UnescapePercentEncoding(ref nextSourceIndex, buffer);
+                if (nextCodeUnit == -1)
+                {
+                    return false;
+                }
+
+                // When UnescapePercentEncoding returns -1 we shall return false.
+                // For performance reasons, there is no separate if statement for the above check
+                // as the condition below also returns -1 for that case.
+                if ((nextCodeUnit & 0xC0) != 0x80)
+                {
+                    // the follow up code unit is not in form of 10xx xxxx
+                    return false;
+                }
+
+                currentDecodeBits = (currentDecodeBits << 6) | (nextCodeUnit & 0x3F);
+                remainingCodeUnits--;
+
+                sourceIndex = nextSourceIndex;
+            }
+
+            if (currentDecodeBits < expectValueMin)
+            {
+                // overlong encoding
+                return false;
+            }
+
+            if (!System.Text.Rune.TryCreate(currentDecodeBits, out var rune) || !rune.TryEncodeToUtf16(buffer.Slice(destinationIndex), out var charsWritten))
+            {
+                // Reasons for this failure could be:
+                // Value is in the range of 0xD800-0xDFFF UTF-16 surrogates that are not allowed in UTF-8
+                // Value is above the upper Unicode bound of 0x10FFFF
+                return false;
+            }
+
+            destinationIndex += charsWritten;
+            return true;
+        }
+
+        /// <summary>
+        /// Read the percent-encoding and try unescape it.
+        ///
+        /// The operation first peek at the character the <paramref name="scan"/>
+        /// iterator points at. If it is % the <paramref name="scan"/> is then
+        /// moved on to scan the following to characters. If the two following
+        /// characters are hexadecimal literals they will be unescaped and the
+        /// value will be returned.
+        ///
+        /// If the first character is not % the <paramref name="scan"/> iterator
+        /// will be removed beyond the location of % and -1 will be returned.
+        ///
+        /// If the following two characters can't be successfully unescaped the
+        /// <paramref name="scan"/> iterator will be move behind the % and -1
+        /// will be returned.
+        /// </summary>
+        /// <param name="scan">The value to read</param>
+        /// <param name="buffer">The char array</param>
+        /// <returns>The unescaped char if success. Otherwise return -1.</returns>
+        private static int UnescapePercentEncoding(ref int scan, ReadOnlySpan<char> buffer)
+        {
+            int tempIdx = scan++;
+            if (buffer[tempIdx] != '%')
+            {
+                return -1;
+            }
+
+            var probe = scan;
+
+            int firstNibble = ReadHex(ref probe, buffer);
+            int secondNibble = ReadHex(ref probe, buffer);
+            int value = firstNibble << 4 | secondNibble;
+
+            // Skip invalid hex values and %2F - '/'
+            if (value < 0 || value == '/')
+            {
+                return -1;
+            }
+            scan = probe;
+            return value;
+        }
+
+        /// <summary>
+        /// Read the next char and convert it into hexadecimal value.
+        ///
+        /// The <paramref name="scan"/> index will be moved to the next
+        /// char no matter whether the operation successes.
+        /// </summary>
+        /// <param name="scan">The index of the char in the buffer to read</param>
+        /// <param name="buffer">The char span from which the hex to be read</param>
+        /// <returns>The hexadecimal value if successes, otherwise -1.</returns>
+        private static int ReadHex(ref int scan, ReadOnlySpan<char> buffer)
+        {
+            // To eliminate boundary checks, using a temporary variable tempIdx.
+            int tempIdx = scan++;
+            if ((uint)tempIdx >= (uint)buffer.Length)
+            {
+                return -1;
+            }
+            int value = buffer[tempIdx];
+
+            return FromChar(value);
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        private static int FromChar(int c)
+        {
+            return (uint)c >= (uint)CharToHexLookup.Length ? -1 : CharToHexLookup[c];
+        }
+
+        private static ReadOnlySpan<sbyte> CharToHexLookup => new sbyte[]
+        {
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 15
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 31
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 47
+            0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9,  -1,  -1,  -1,  -1,  -1,  -1, // 63
+             -1, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 79
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 95
+             -1, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 111
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 127
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 143
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 159
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 175
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 191
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 207
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 223
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1, // 239
+             -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1,  -1  // 255
+        };
     }
 }

+ 2 - 1
src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks>
@@ -32,6 +32,7 @@
     <Compile Include="$(SharedSourceRoot)ValueStopwatch\**\*.cs" Link="Shared\ValueStopwatch\%(Filename)%(Extension)"/>
     <Compile Include="$(SharedSourceRoot)WebEncoders\**\*.cs" Link="Shared\WebEncoders\%(Filename)%(Extension)"/>
     <Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" />
+    <Compile Include="$(SharedSourceRoot)UrlDecoder\**\*.cs" Link="Shared\UrlDecoder\%(Filename)%(Extension)"/>
     <Compile Include="$(SharedSourceRoot)TypeNameHelper\*.cs" />
     <Compile Include="$(SharedSourceRoot)TaskToApm.cs" />
   </ItemGroup>

+ 201 - 0
src/Shared/test/Shared.Tests/UrlDecoderTests.cs

@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.AspNetCore.Internal;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Shared.Tests
+{
+    public class UrlDecoderTests
+    {
+        [Theory]
+        [MemberData(nameof(PathTestData))]
+        public void StringDecodeRequestLine(string input, string expected)
+        {
+            var destination = new char[input.Length];
+            int length = UrlDecoder.DecodeRequestLine(input.AsSpan(), destination.AsSpan());
+            Assert.True(destination.AsSpan(0, length).SequenceEqual(expected.AsSpan()));
+        }
+
+        [Theory]
+        [MemberData(nameof(UriTestData))]
+        public void ByteDecodeRequestLine(byte[] input, byte[] expected)
+        {
+            var destination = new byte[input.Length];
+            int length = UrlDecoder.DecodeRequestLine(input.AsSpan(), destination.AsSpan(), false);
+            Assert.True(destination.AsSpan(0, length).SequenceEqual(expected.AsSpan()));
+        }
+
+        [Theory]
+        [MemberData(nameof(PathTestData))]
+        public void StringDecodeInPlace(string input, string expected)
+        {
+            var destination = new char[input.Length];
+            input.CopyTo(destination);
+            int length = UrlDecoder.DecodeInPlace(destination.AsSpan());
+            Assert.True(destination.AsSpan(0, length).SequenceEqual(expected.AsSpan()));
+        }
+
+        [Theory]
+        [MemberData(nameof(UriTestData))]
+        public void ByteDecodeInPlace(byte[] input, byte[] expected)
+        {
+            var destination = new byte[input.Length];
+            input.AsSpan().CopyTo(destination);
+            int length = UrlDecoder.DecodeInPlace(destination.AsSpan(), false);
+            Assert.True(destination.AsSpan(0, length).SequenceEqual(expected.AsSpan()));
+        }
+
+        [Fact]
+        public void StringDestinationShorterThanSourceDecodeRequestLineThrows()
+        {
+            var source = new char[2];
+            Assert.Throws<ArgumentException>(() => UrlDecoder.DecodeRequestLine(source.AsSpan(), source.AsSpan(0, 1)));
+        }
+
+        [Fact]
+        public void ByteDestinationShorterThanSourceDecodeRequestLineThrows()
+        {
+            var source = new byte[2];
+            Assert.Throws<ArgumentException>(() => UrlDecoder.DecodeRequestLine(source.AsSpan(), source.AsSpan(0, 1), false));
+        }
+
+        [Fact]
+        public void StringDestinationLargerThanSourceDecodeRequestLineReturnsCorrenctLenght()
+        {
+            var source = "/a%20b".ToCharArray();
+            var length = UrlDecoder.DecodeRequestLine(source.AsSpan(), new char[source.Length + 10]);
+            Assert.Equal(4, length);
+        }
+
+        [Fact]
+        public void ByteDestinationLargerThanSourceDecodeRequestLineReturnsCorrenctLenght()
+        {
+            var source = Encoding.UTF8.GetBytes("/a%20b".ToCharArray());
+            var length = UrlDecoder.DecodeRequestLine(source.AsSpan(), new byte[source.Length + 10], false);
+            Assert.Equal(4, length);
+        }
+
+        [Fact]
+        public void StringInputNullCharDecodeInPlaceThrows()
+        {
+            var source = "%00".ToCharArray();
+            Assert.Throws<InvalidOperationException>(() => UrlDecoder.DecodeInPlace(source.AsSpan()));
+        }
+
+        [Fact]
+        public void ByteInputNullCharDecodeInPlaceThrows()
+        {
+            var source = Encoding.UTF8.GetBytes("%00");
+            Assert.Throws<InvalidOperationException>(() => UrlDecoder.DecodeInPlace(source.AsSpan(), false));
+        }
+
+        [Theory]
+        [InlineData("%$$")]
+        [InlineData("%1")]
+        [InlineData("%1$")]
+        [InlineData("%%1")]
+        [InlineData("%%1$")]
+        public void StringInputNonHexDecodeInPlaceLeavesUnencoded(string input)
+        {
+            var source = input.ToCharArray();
+            var length = UrlDecoder.DecodeInPlace(source.AsSpan());
+            Assert.Equal(input.Length, length);
+            Assert.True(source.AsSpan(0, length).SequenceEqual(input.AsSpan()));
+        }
+
+        [Theory]
+        [InlineData("%$$")]
+        [InlineData("%1")]
+        [InlineData("%1$")]
+        [InlineData("%%1")]
+        [InlineData("%%1$")]
+        public void ByteInputNonHexDecodeInPlaceLeavesUnencoded(string input)
+        {
+            var source = Encoding.UTF8.GetBytes(input.ToCharArray());
+            var length = UrlDecoder.DecodeInPlace(source.AsSpan(), false);
+            Assert.Equal(source.Length, length);
+            Assert.True(source.AsSpan(0, length).SequenceEqual(Encoding.UTF8.GetBytes(input).AsSpan()));
+        }
+
+        [Theory]
+        [InlineData("%2F")]
+        public void ByteFormsEncodingDecodeInPlaceDecodesPercent2F(string input)
+        {
+            var source = Encoding.UTF8.GetBytes(input.ToCharArray());
+            var length = UrlDecoder.DecodeInPlace(source.AsSpan(), true);
+            Assert.Equal(1, length);
+            Assert.True(source.AsSpan(0, length).SequenceEqual(Encoding.UTF8.GetBytes("/").AsSpan()));
+        }
+
+        [Theory]
+        [InlineData("%FF%FF%FF%FF")] // FF invalid first byte
+        [InlineData("%F7%BF%BF%BF")] // beyond 0x10FFFF
+        [InlineData("%F7%C0")] // Following byte does not start with 10xx xxxx
+        [InlineData("%F0%81")] // Not enough bytes
+        [InlineData("%ED%A0%81")] // Invalid range 0xD800-0xDFFF
+        public void StringOutOfUtf8RangeDecodeInPlaceLeavesUnencoded(string input)
+        {
+            var source = input.ToCharArray();
+            var length = UrlDecoder.DecodeInPlace(source.AsSpan());
+            Assert.Equal(input.Length, length);
+            Assert.True(source.AsSpan(0, length).SequenceEqual(input.AsSpan()));
+        }
+
+        [Theory]
+        [InlineData("%FF%FF%FF%FF")] // FF invalid first byte
+        [InlineData("%F7%BF%BF%BF")] // beyond 0x10FFFF
+        [InlineData("%F7%C0")] // Following byte does not start with 10xx xxxx
+        [InlineData("%F0%81")] // Not enough bytes
+        [InlineData("%ED%A0%81")] // Invalid range 0xD800-0xDFFF
+        public void ByteOutOfUtf8RangeDecodeInPlaceLeavesUnencoded(string input)
+        {
+            var source = Encoding.UTF8.GetBytes(input.ToCharArray());
+            var length = UrlDecoder.DecodeInPlace(source.AsSpan(), true);
+            Assert.Equal(source.Length, length);
+            Assert.True(source.AsSpan(0, length).SequenceEqual(Encoding.UTF8.GetBytes(input).AsSpan()));
+        }
+
+        public static IEnumerable<object[]> PathTestData
+        {
+            get
+            {
+                return new List<object[]>()
+                {
+                    new[] { "hello", "hello" },
+                    new[] { "/", "/" },
+                    new[] { "http://localhost:5000/api", "http://localhost:5000/api" },
+                    new[] { "/api/abc", "/api/abc" },
+                    new[] { "/api/a%2Fb", "/api/a%2Fb" },
+                    new[] { "/a%20b", "/a b" },
+                    new[] { "/a%24b", "/a$b" },
+                    new[] { "/a%C2%A2b", "/a¢b" },
+                    new[] { "/a%E0%A4%B9b", "/aहb" },
+                    new[] { "/a%E2%82%ACb", "/a€b" },
+                    new[] { "/a%ED%95%9Cb", "/a한b" },
+                    new[] { "/a%F0%90%8D%88b", "/a𐍈b" },
+                    new[] { "/a%25b", "/a%b" },
+                    new[] { "/%E4%BD%A0%E5%A5%BD", "/你好" },
+                    new[] { "/a%%2Fb", "/a%%2Fb" },
+                    new[] { "/a%2Fb+c", "/a%2Fb+c" },
+                    new[] { "/%C3%C3%A1", "/%C3á" },
+                    new[] { "/a%20%%b", "/a %%b" },
+                };
+            }
+        }
+
+        public static IEnumerable<object[]> UriTestData
+        {
+            get
+            {
+                return PathTestData.Select(x =>
+                {
+                    var input = Encoding.UTF8.GetBytes((string)x[0]);
+                    var expected = Encoding.UTF8.GetBytes((string)x[1]);
+                    return new[] { input, expected };
+                });
+            }
+        }
+    }
+}