Skip to content

Commit ace334c

Browse files
committed
[Blazor] Fixed encoded values on Blazor Router
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 429bd8b commit ace334c

File tree

11 files changed

+248
-17
lines changed

11 files changed

+248
-17
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
2222
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryItemDebugView.cs" LinkBase="Shared" />
2323
<Compile Include="$(SharedSourceRoot)Debugger\DictionaryDebugView.cs" LinkBase="Shared" />
24+
<Compile Include="$(SharedSourceRoot)UrlDecoder\UrlDecoder.cs" LinkBase="Shared" />
2425
</ItemGroup>
2526

2627
<Import Project="Microsoft.AspNetCore.Components.Routing.targets" />

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/Components/src/Routing/RouteTableFactory.cs

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Microsoft.Extensions.Logging;
1414
using Microsoft.Extensions.Options;
1515
using static Microsoft.AspNetCore.Internal.LinkerFlags;
16+
using Microsoft.AspNetCore.Routing.Constraints;
1617

1718
namespace Microsoft.AspNetCore.Components;
1819

@@ -112,9 +113,11 @@ private static string[] GetTemplates(Type componentType)
112113
[UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Application code does not get trimmed, and the framework does not define routable components.")]
113114
internal static RouteTable Create(Dictionary<Type, string[]> templatesByHandler, IServiceProvider serviceProvider)
114115
{
116+
var routeOptions = Options.Create(new RouteOptions());
117+
routeOptions.Value.SetParameterPolicy("regex", typeof(RegexInlineRouteConstraint));
115118
var builder = new TreeRouteBuilder(
116119
serviceProvider.GetRequiredService<ILoggerFactory>(),
117-
new DefaultInlineConstraintResolver(Options.Create(new RouteOptions()), serviceProvider));
120+
new DefaultInlineConstraintResolver(routeOptions, serviceProvider));
118121

119122
foreach (var (type, templates) in templatesByHandler)
120123
{

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

+27-15
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,30 @@ 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+
[InlineData("%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback", "http://www.example.com/login/callback")]
30+
// Note this double encodes the final 2 slashes
31+
[InlineData("%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%252Flogin%252Fcallback", "http://www.example.com%2Flogin%2Fcallback")]
32+
public void Routing_CanRenderPagesWithParameters_And_TransitionToInteractive(string url, string expectedValue)
2933
{
30-
ExecuteRoutingTestCore("routing/parameters/value", "value");
34+
ExecuteRoutingTestCore(url, expectedValue);
3135
}
3236

33-
[Fact]
34-
public void Routing_CanRenderPagesWithConstrainedParameters_And_TransitionToInteractive()
37+
[Theory]
38+
[InlineData("routing/constraints/5", "5")]
39+
[InlineData("%F0%9F%99%82/routing/constraints/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback", "http://www.example.com/login/callback")]
40+
public void Routing_CanRenderPagesWithConstrainedParameters_And_TransitionToInteractive(string url, string expectedValue)
3541
{
36-
ExecuteRoutingTestCore("routing/constraints/5", "5");
42+
ExecuteRoutingTestCore(url, expectedValue);
3743
}
3844

39-
[Fact]
40-
public void Routing_CanRenderPagesWithComplexSegments_And_TransitionToInteractive()
45+
[Theory]
46+
[InlineData("routing/complex-segment(value)", "value")]
47+
[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")]
48+
public void Routing_CanRenderPagesWithComplexSegments_And_TransitionToInteractive(string url, string expectedValue)
4149
{
42-
ExecuteRoutingTestCore("routing/complex-segment(value)", "value");
50+
ExecuteRoutingTestCore(url, expectedValue);
4351
}
4452

4553
[Fact]
@@ -54,16 +62,20 @@ public void Routing_CanRenderPagesWithOptionalParameters_And_TransitionToInterac
5462
ExecuteRoutingTestCore("routing/optional", "null");
5563
}
5664

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

63-
[Fact]
64-
public void Routing_CanRenderPagesWithConstrainedCatchAllParameters_And_TransitionToInteractive()
73+
[Theory]
74+
[InlineData("routing/constrained-catch-all/a/b", "a/b")]
75+
[InlineData("%F0%9F%99%82/routing/constrained-catch-all/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback/another", "http://www.example.com/login/callback/another")]
76+
public void Routing_CanRenderPagesWithConstrainedCatchAllParameters_And_TransitionToInteractive(string url, string expectedValue)
6577
{
66-
ExecuteRoutingTestCore("routing/constrained-catch-all/a/b", "a/b");
78+
ExecuteRoutingTestCore(url, expectedValue);
6779
}
6880

6981
private void ExecuteRoutingTestCore(string url, string expectedValue)
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/constrained-catch-all/{*parameter:regex(http:%2F%2Fwww\\.example\\.com%2Flogin%2Fcallback\\/another)}"
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,24 @@
1+
@page "/🙂/routing/constraints/{parameter:regex(http:%2F%2Fwww\\.example\\.com%2Flogin%2Fcallback)}"
2+
<h3>Constrained 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,21 @@
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/constrained-catch-all/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback/another">Constrained catch all</a>
11+
</li>
12+
<li>
13+
<a href="%F0%9F%99%82/routing/constraints/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback">Constrained parameters</a>
14+
</li>
15+
<li>
16+
<a href="%F0%9F%99%82/routing/parameters/http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback">Parameters</a>
17+
</li>
18+
<li>
19+
<a href="%F0%9F%99%82/routing/%F0%9F%99%82complex-segment(http%3A%2F%2Fwww.example.com%2Flogin%2Fcallback)">Complex segments</a>
20+
</li>
21+
</ul>

0 commit comments

Comments
 (0)