Skip to content

Commit 6412406

Browse files
committed
[Blazor] Fixed encoded values on Blazor Router (#53470)
As part of the routing unification process we switched the way we were decoding the URL prior to feeding it to routing and that introduced a small regression in interactive routing compared to .NET 7.0. This commit fixes that regression by using the same logic for decoding the URL in the client that is used on the server. In addition to that, the Blazor router now post processes the URL to replace instances of `%2F` with `/` when providing values to maintain the behavior in 7.0 where it used UnescapeDataString. This also makes the routing on the server and on the client consistent in their handling of encoded `/` characters.
1 parent e91e94d commit 6412406

File tree

8 files changed

+186
-11
lines changed

8 files changed

+186
-11
lines changed

src/Components/Components/src/Microsoft.AspNetCore.Components.csproj

+2-1
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadManager.cs" LinkBase="HotReload" />
2020
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
2121
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
22+
<Compile Include="$(SharedSourceRoot)UrlDecoder\UrlDecoder.cs" LinkBase="Shared" />
2223
</ItemGroup>
2324

2425
<Import Project="Microsoft.AspNetCore.Components.Routing.targets" />
25-
26+
2627
<ItemGroup>
2728
<Reference Include="Microsoft.Extensions.Logging.Abstractions" />
2829
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />

src/Components/Components/src/Routing/RouteContext.cs

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Buffers;
45
using System.Diagnostics.CodeAnalysis;
6+
using System.Runtime.CompilerServices;
7+
using System.Text;
8+
using Microsoft.AspNetCore.Internal;
59
using Microsoft.AspNetCore.Routing.Tree;
610
using static Microsoft.AspNetCore.Internal.LinkerFlags;
711

@@ -11,7 +15,28 @@ internal sealed class RouteContext
1115
{
1216
public RouteContext(string path)
1317
{
14-
Path = Uri.UnescapeDataString(path);
18+
Path = path.Contains('%') ? GetDecodedPath(path) : path;
19+
20+
[SkipLocalsInit]
21+
static string GetDecodedPath(string path)
22+
{
23+
using var uriBuffer = path.Length < 128 ?
24+
new UriBuffer(stackalloc byte[path.Length]) :
25+
new UriBuffer(path.Length);
26+
27+
var utf8Span = uriBuffer.Buffer;
28+
29+
if (Encoding.UTF8.TryGetBytes(path.AsSpan(), utf8Span, out var written))
30+
{
31+
utf8Span = utf8Span[..written];
32+
var decodedLength = UrlDecoder.DecodeInPlace(utf8Span, isFormEncoding: false);
33+
utf8Span = utf8Span[..decodedLength];
34+
path = Encoding.UTF8.GetString(utf8Span);
35+
return path;
36+
}
37+
38+
return path;
39+
}
1540
}
1641

1742
public string Path { get; set; }
@@ -24,4 +49,27 @@ public RouteContext(string path)
2449
public Type? Handler => Entry?.Handler;
2550

2651
public IReadOnlyDictionary<string, object?>? Parameters => RouteValues;
52+
53+
private readonly ref struct UriBuffer
54+
{
55+
private readonly byte[]? _pooled;
56+
57+
public Span<byte> Buffer { get; }
58+
59+
public UriBuffer(int length)
60+
{
61+
_pooled = ArrayPool<byte>.Shared.Rent(length);
62+
Buffer = _pooled.AsSpan(0, length);
63+
}
64+
65+
public UriBuffer(Span<byte> buffer) => Buffer = buffer;
66+
67+
public void Dispose()
68+
{
69+
if (_pooled != null)
70+
{
71+
ArrayPool<byte>.Shared.Return(_pooled);
72+
}
73+
}
74+
}
2775
}

src/Components/Components/src/Routing/RouteTable.cs

+25
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ internal static RouteData ProcessParameters(RouteData endpointRouteData)
2828
((Type page, string template) key) => RouteTableFactory.CreateEntry(key.page, key.template));
2929

3030
var routeValueDictionary = new RouteValueDictionary(endpointRouteData.RouteValues);
31+
foreach (var kvp in endpointRouteData.RouteValues)
32+
{
33+
if (kvp.Value is string value)
34+
{
35+
// At this point the values have already been URL decoded, but we might not have decoded '/' characters.
36+
// as that can cause issues when routing the request (You wouldn't be able to accept parameters that contained '/').
37+
// To be consistent with existing Blazor quirks that used Uri.UnescapeDataString, we'll replace %2F with /.
38+
// We don't want to call Uri.UnescapeDataString here as that would decode other characters that we don't want to decode,
39+
// for example, any value that was "double" encoded (for whatever reason) within the original URL.
40+
routeValueDictionary[kvp.Key] = value.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
41+
}
42+
}
3143
ProcessParameters(entry, routeValueDictionary);
3244
return new RouteData(endpointRouteData.PageType, routeValueDictionary)
3345
{
@@ -66,6 +78,19 @@ private static void ProcessParameters(InboundRouteEntry entry, RouteValueDiction
6678
}
6779
}
6880

81+
foreach (var kvp in routeValues)
82+
{
83+
if (kvp.Value is string value)
84+
{
85+
// At this point the values have already been URL decoded, but we might not have decoded '/' characters.
86+
// as that can cause issues when routing the request (You wouldn't be able to accept parameters that contained '/').
87+
// To be consistent with existing Blazor quirks that used Uri.UnescapeDataString, we'll replace %2F with /.
88+
// We don't want to call Uri.UnescapeDataString here as that would decode other characters that we don't want to decode,
89+
// for example, any value that was "double" encoded (for whatever reason) within the original URL.
90+
routeValues[kvp.Key] = value.Replace("%2F", "/", StringComparison.OrdinalIgnoreCase);
91+
}
92+
}
93+
6994
foreach (var parameter in entry.RoutePattern.Parameters)
7095
{
7196
// Add null values for optional route parameters that weren't provided.

src/Components/test/E2ETest/ServerRenderingTests/UnifiedRoutingTests.cs

+22-9
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,19 @@ public UnifiedRoutingTests(
2424
public override Task InitializeAsync()
2525
=> InitializeAsync(BrowserFixture.StreamingContext);
2626

27-
[Fact]
28-
public void Routing_CanRenderPagesWithParameters_And_TransitionToInteractive()
27+
[Theory]
28+
[InlineData("routing/parameters/value", "value")]
29+
// Issue 53138
30+
[InlineData("%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback", "http://www.example.com/login/callback")]
31+
// Note this double encodes the final 2 slashes
32+
[InlineData("%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%2520login%2520callback", "http://www.example.com%20login%20callback")]
33+
// Issue 53262
34+
[InlineData("routing/parameters/%21%40%23%24%25%5E%26%2A%28%29_%2B-%3D%5B%5D%7B%7D%5C%5C%7C%3B%27%3A%5C%22%3E%3F.%2F", """!@#$%^&*()_+-=[]{}\\|;':\">?./""")]
35+
// Issue 52808
36+
[InlineData("routing/parameters/parts%20w%2F%20issue", "parts w/ issue")]
37+
public void Routing_CanRenderPagesWithParameters_And_TransitionToInteractive(string url, string expectedValue)
2938
{
30-
ExecuteRoutingTestCore("routing/parameters/value", "value");
39+
ExecuteRoutingTestCore(url, expectedValue);
3140
}
3241

3342
[Fact]
@@ -36,10 +45,12 @@ public void Routing_CanRenderPagesWithConstrainedParameters_And_TransitionToInte
3645
ExecuteRoutingTestCore("routing/constraints/5", "5");
3746
}
3847

39-
[Fact]
40-
public void Routing_CanRenderPagesWithComplexSegments_And_TransitionToInteractive()
48+
[Theory]
49+
[InlineData("routing/complex-segment(value)", "value")]
50+
[InlineData("%F0%9F%99%82/routing/%F0%9F%99%82complex-segment(http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback)", "http://www.example.com/login/callback")]
51+
public void Routing_CanRenderPagesWithComplexSegments_And_TransitionToInteractive(string url, string expectedValue)
4152
{
42-
ExecuteRoutingTestCore("routing/complex-segment(value)", "value");
53+
ExecuteRoutingTestCore(url, expectedValue);
4354
}
4455

4556
[Fact]
@@ -54,10 +65,12 @@ public void Routing_CanRenderPagesWithOptionalParameters_And_TransitionToInterac
5465
ExecuteRoutingTestCore("routing/optional", "null");
5566
}
5667

57-
[Fact]
58-
public void Routing_CanRenderPagesWithCatchAllParameters_And_TransitionToInteractive()
68+
[Theory]
69+
[InlineData("routing/catch-all/rest/of/the/path", "rest/of/the/path")]
70+
[InlineData("%F0%9F%99%82/routing/catch-all/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback/another", "http://www.example.com/login/callback/another")]
71+
public void Routing_CanRenderPagesWithCatchAllParameters_And_TransitionToInteractive(string url, string expectedValue)
5972
{
60-
ExecuteRoutingTestCore("routing/catch-all/rest/of/the/path", "rest/of/the/path");
73+
ExecuteRoutingTestCore(url, expectedValue);
6174
}
6275

6376
[Fact]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
@page "/🙂/routing/catch-all/{*parameter}"
2+
3+
<h3>Catch all</h3>
4+
5+
<p id="parameter-value">@Parameter</p>
6+
7+
@if (_interactive)
8+
{
9+
<p id="interactive">Rendered interactive.</p>
10+
}
11+
12+
@code {
13+
private bool _interactive;
14+
15+
[Parameter] public string Parameter { get; set; }
16+
17+
protected override void OnAfterRender(bool firstRender)
18+
{
19+
if (firstRender)
20+
{
21+
_interactive = true;
22+
StateHasChanged();
23+
}
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@page "/🙂/routing/🙂complex-segment({parameter})"
2+
<h3>Complex segment parameters</h3>
3+
4+
<p id="parameter-value">@Parameter</p>
5+
6+
@if(_interactive)
7+
{
8+
<p id="interactive">Rendered interactive.</p>
9+
}
10+
11+
@code {
12+
private bool _interactive;
13+
14+
[Parameter] public string Parameter { get; set; }
15+
16+
protected override void OnAfterRender(bool firstRender)
17+
{
18+
if (firstRender)
19+
{
20+
_interactive = true;
21+
StateHasChanged();
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@page "/🙂/routing/parameters/{parameter}"
2+
<h3>Parameters</h3>
3+
4+
<p id="parameter-value">@Parameter</p>
5+
6+
@if (_interactive)
7+
{
8+
<p id="interactive">Rendered interactive.</p>
9+
}
10+
11+
@code {
12+
private bool _interactive;
13+
14+
[Parameter] public string Parameter { get; set; }
15+
16+
protected override void OnAfterRender(bool firstRender)
17+
{
18+
if (firstRender)
19+
{
20+
_interactive = true;
21+
StateHasChanged();
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@page "/🙂/routing"
2+
@inject NavigationManager NavigationManager
3+
<h3>Routing test cases with encoded urls</h3>
4+
5+
<ul>
6+
<li>
7+
<a href="%F0%9F%99%82/routing/catch-all/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback/another">Catch all</a>
8+
</li>
9+
<li>
10+
<a href="%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback">Parameters</a>
11+
</li>
12+
<li>
13+
<a href="%F0%9F%99%82/routing/%F0%9F%99%82complex-segment(http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback)">Complex segments</a>
14+
</li>
15+
</ul>

0 commit comments

Comments
 (0)