Skip to content

Commit 87b761d

Browse files
authored
Route parameter with urlencoded slash inconsistency between TestServer and Kestrel behavior (#33364)
1 parent 0f37ede commit 87b761d

File tree

11 files changed

+679
-15
lines changed

11 files changed

+679
-15
lines changed

src/Hosting/TestHost/test/ClientHandlerTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@ namespace Microsoft.AspNetCore.TestHost
2424
{
2525
public class ClientHandlerTests
2626
{
27+
[Fact]
28+
public async Task SlashUrlEncodedDoesNotGetDecoded()
29+
{
30+
var handler = new ClientHandler(new PathString(), new InspectingApplication(features =>
31+
{
32+
Assert.True(features.Get<IHttpRequestLifetimeFeature>().RequestAborted.CanBeCanceled);
33+
Assert.Equal(HttpProtocol.Http11, features.Get<IHttpRequestFeature>().Protocol);
34+
Assert.Equal("GET", features.Get<IHttpRequestFeature>().Method);
35+
Assert.Equal("https", features.Get<IHttpRequestFeature>().Scheme);
36+
Assert.Equal("/api/a%2Fb c", features.Get<IHttpRequestFeature>().Path);
37+
Assert.NotNull(features.Get<IHttpRequestFeature>().Body);
38+
Assert.NotNull(features.Get<IHttpRequestFeature>().Headers);
39+
Assert.NotNull(features.Get<IHttpResponseFeature>().Headers);
40+
Assert.NotNull(features.Get<IHttpResponseBodyFeature>().Stream);
41+
Assert.Equal(200, features.Get<IHttpResponseFeature>().StatusCode);
42+
Assert.Null(features.Get<IHttpResponseFeature>().ReasonPhrase);
43+
Assert.Equal("example.com", features.Get<IHttpRequestFeature>().Headers["host"]);
44+
Assert.NotNull(features.Get<IHttpRequestLifetimeFeature>());
45+
}));
46+
var httpClient = new HttpClient(handler);
47+
await httpClient.GetAsync("https://example.com/api/a%2Fb%20c");
48+
}
49+
2750
[Fact]
2851
public Task ExpectedKeysAreAvailable()
2952
{
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using BenchmarkDotNet.Attributes;
4+
5+
namespace Microsoft.AspNetCore.Http.Abstractions.Microbenchmarks
6+
{
7+
public class PathStringBenchmark
8+
{
9+
private const string TestPath = "/api/a%2Fb/c";
10+
private const string LongTestPath = "/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b";
11+
private const string LongTestPathEarlyPercent = "/t%20hisMustBeAVeryLongPath/SoLongButStillShorterToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeap/api/a%20b";
12+
13+
public IEnumerable<object> TestPaths => new[] { TestPath, LongTestPath, LongTestPathEarlyPercent };
14+
15+
public IEnumerable<object> TestUris => new[] { new Uri($"https://localhost:5001/{TestPath}"), new Uri($"https://localhost:5001/{LongTestPath}"), new Uri($"https://localhost:5001/{LongTestPathEarlyPercent}") };
16+
17+
[Benchmark]
18+
[ArgumentsSource(nameof(TestPaths))]
19+
public string OnPathFromUriComponent(string testPath)
20+
{
21+
var pathString = PathString.FromUriComponent(testPath);
22+
return pathString.Value;
23+
}
24+
25+
[Benchmark]
26+
[ArgumentsSource(nameof(TestUris))]
27+
public string OnUriFromUriComponent(Uri testUri)
28+
{
29+
var pathString = PathString.FromUriComponent(testUri);
30+
return pathString.Value;
31+
}
32+
}
33+
}

src/Http/Http.Abstractions/src/Microsoft.AspNetCore.Http.Abstractions.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Microsoft.AspNetCore.Http.HttpResponse</Description>
2424
<Compile Include="$(SharedSourceRoot)ActivatorUtilities\*.cs" />
2525
<Compile Include="$(SharedSourceRoot)ParameterDefaultValue\*.cs" />
2626
<Compile Include="$(SharedSourceRoot)PropertyHelper\**\*.cs" />
27+
<Compile Include="$(SharedSourceRoot)\UrlDecoder\UrlDecoder.cs" Link="UrlDecoder.cs" />
2728
</ItemGroup>
2829

2930
<ItemGroup>

src/Http/Http.Abstractions/src/PathString.cs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Globalization;
88
using System.Text;
99
using Microsoft.AspNetCore.Http.Abstractions;
10+
using Microsoft.AspNetCore.Internal;
1011

1112
namespace Microsoft.AspNetCore.Http
1213
{
@@ -16,6 +17,8 @@ namespace Microsoft.AspNetCore.Http
1617
[TypeConverter(typeof(PathStringConverter))]
1718
public readonly struct PathString : IEquatable<PathString>
1819
{
20+
internal const int StackAllocThreshold = 128;
21+
1922
/// <summary>
2023
/// Represents the empty path. This field is read-only.
2124
/// </summary>
@@ -172,8 +175,16 @@ private static string ToEscapedUriComponent(string value, int i)
172175
/// <returns>The resulting PathString</returns>
173176
public static PathString FromUriComponent(string uriComponent)
174177
{
175-
// REVIEW: what is the exactly correct thing to do?
176-
return new PathString(Uri.UnescapeDataString(uriComponent));
178+
int position = uriComponent.IndexOf('%');
179+
if (position == -1)
180+
{
181+
return new PathString(uriComponent);
182+
}
183+
Span<char> pathBuffer = uriComponent.Length <= StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length];
184+
uriComponent.CopyTo(pathBuffer);
185+
var length = UrlDecoder.DecodeInPlace(pathBuffer.Slice(position, uriComponent.Length - position));
186+
pathBuffer = pathBuffer.Slice(0, position + length);
187+
return new PathString(pathBuffer.ToString());
177188
}
178189

179190
/// <summary>
@@ -187,9 +198,12 @@ public static PathString FromUriComponent(Uri uri)
187198
{
188199
throw new ArgumentNullException(nameof(uri));
189200
}
190-
191-
// REVIEW: what is the exactly correct thing to do?
192-
return new PathString("/" + uri.GetComponents(UriComponents.Path, UriFormat.Unescaped));
201+
var uriComponent = uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped);
202+
Span<char> pathBuffer = uriComponent.Length < StackAllocThreshold ? stackalloc char[StackAllocThreshold] : new char[uriComponent.Length + 1];
203+
pathBuffer[0] = '/';
204+
var length = UrlDecoder.DecodeRequestLine(uriComponent.AsSpan(), pathBuffer.Slice(1));
205+
pathBuffer = pathBuffer.Slice(0, length + 1);
206+
return new PathString(pathBuffer.ToString());
193207
}
194208

195209
/// <summary>

src/Http/Http.Abstractions/test/PathStringTests.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.ComponentModel;
7+
using System.Globalization;
8+
using System.Linq;
69
using Microsoft.AspNetCore.Testing;
710
using Xunit;
811

@@ -236,5 +239,114 @@ public void PathStringStaysEqualAfterAssignments()
236239
PathString p2 = s1;
237240
Assert.Equal(p1, p2);
238241
}
242+
243+
[Theory]
244+
[InlineData("/a%2Fb")]
245+
[InlineData("/a%2F")]
246+
[InlineData("/%2fb")]
247+
[InlineData("/a%2Fb/c%2Fd/e")]
248+
public void StringFromUriComponentLeavesForwardSlashEscaped(string input)
249+
{
250+
var sut = PathString.FromUriComponent(input);
251+
Assert.Equal(input, sut.Value);
252+
}
253+
254+
[Theory]
255+
[InlineData("/a%2Fb")]
256+
[InlineData("/a%2F")]
257+
[InlineData("/%2fb")]
258+
[InlineData("/a%2Fb/c%2Fd/e")]
259+
public void UriFromUriComponentLeavesForwardSlashEscaped(string input)
260+
{
261+
var uri = new Uri($"https://localhost:5001{input}");
262+
var sut = PathString.FromUriComponent(uri);
263+
Assert.Equal(input, sut.Value);
264+
}
265+
266+
[Theory]
267+
[InlineData("/a%20b", "/a b")]
268+
[InlineData("/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b",
269+
"/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a b")]
270+
public void StringFromUriComponentUnescapes(string input, string expected)
271+
{
272+
var sut = PathString.FromUriComponent(input);
273+
Assert.Equal(expected, sut.Value);
274+
}
275+
276+
[Theory]
277+
[InlineData("/a%20b", "/a b")]
278+
[InlineData("/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a%20b",
279+
"/thisMustBeAVeryLongPath/SoLongThatItCouldActuallyBeLargerToTheStackAllocThresholdValue/PathsShorterToThisAllocateLessOnHeapByUsingStackAllocation/api/a b")]
280+
public void UriFromUriComponentUnescapes(string input, string expected)
281+
{
282+
var uri = new Uri($"https://localhost:5001{input}");
283+
var sut = PathString.FromUriComponent(uri);
284+
Assert.Equal(expected, sut.Value);
285+
}
286+
287+
[Theory]
288+
[InlineData("/a%2Fb")]
289+
[InlineData("/a%2F")]
290+
[InlineData("/%2fb")]
291+
[InlineData("/%2Fb%20c")]
292+
[InlineData("/a%2Fb%20c")]
293+
[InlineData("/a%20b")]
294+
[InlineData("/a%2Fb/c%2Fd/e%20f")]
295+
[InlineData("/%E4%BD%A0%E5%A5%BD")]
296+
public void FromUriComponentToUriComponent(string input)
297+
{
298+
var sut = PathString.FromUriComponent(input);
299+
Assert.Equal(input, sut.ToUriComponent());
300+
}
301+
302+
[Theory]
303+
[MemberData(nameof(CharsToUnescape))]
304+
[InlineData("/%E4%BD%A0%E5%A5%BD", "/你好")]
305+
public void FromUriComponentUnescapesAllExceptForwardSlash(string input, string expected)
306+
{
307+
var sut = PathString.FromUriComponent(input);
308+
Assert.Equal(expected, sut.Value);
309+
}
310+
311+
[Theory]
312+
[InlineData(-1)]
313+
[InlineData(0)]
314+
[InlineData(1)]
315+
public void ExercisingStringFromUriComponentOnStackAllocLimit(int offset)
316+
{
317+
var path = "/";
318+
var testString = new string('a', PathString.StackAllocThreshold + offset - path.Length);
319+
var sut = PathString.FromUriComponent(path + testString);
320+
Assert.Equal(PathString.StackAllocThreshold + offset, sut.Value!.Length);
321+
}
322+
323+
[Theory]
324+
[InlineData(-1)]
325+
[InlineData(0)]
326+
[InlineData(1)]
327+
public void ExercisingUriFromUriComponentOnStackAllocLimit(int offset)
328+
{
329+
var localhost = "https://localhost:5001/";
330+
var testString = new string('a', PathString.StackAllocThreshold + offset);
331+
var sut = PathString.FromUriComponent(new Uri(localhost + testString));
332+
Assert.Equal(PathString.StackAllocThreshold + offset + 1, sut.Value!.Length);
333+
}
334+
335+
public static IEnumerable<object[]> CharsToUnescape
336+
{
337+
get
338+
{
339+
foreach (var item in Enumerable.Range(1, 127))
340+
{
341+
// %2F is '/' not escaped for paths
342+
if (item != 0x2f)
343+
{
344+
var hexEscapedValue = "%" + item.ToString("x2", CultureInfo.InvariantCulture);
345+
var expected = Uri.UnescapeDataString(hexEscapedValue);
346+
yield return new object[] { "/a" + hexEscapedValue, "/a" + expected };
347+
}
348+
}
349+
}
350+
}
239351
}
240352
}

src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamic.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpC
6161
var results = new RouteValueDictionary();
6262
foreach (var kvp in kvps)
6363
{
64-
var split = kvp.Split("=");
64+
var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
6565
results[split[0]] = split[1];
6666
}
6767

src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamicAndRazorPages.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System;
45
using System.Threading.Tasks;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Http;
@@ -50,7 +51,7 @@ public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpC
5051
var results = new RouteValueDictionary();
5152
foreach (var kvp in kvps)
5253
{
53-
var split = kvp.Split("=");
54+
var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
5455
results[split[0]] = split[1];
5556
}
5657

src/Mvc/test/WebSites/RoutingWebSite/StartupForDynamicOrder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ public override ValueTask<RouteValueDictionary> TransformAsync(HttpContext httpC
115115

116116
foreach (var kvp in kvps)
117117
{
118-
var split = kvp.Split("=");
118+
var split = kvp.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase).Split("=");
119119
if (split.Length == 2)
120120
{
121121
results[split[0]] = split[1];

0 commit comments

Comments
 (0)