Skip to content

Commit 0005026

Browse files
Make enhanced nav much more conservative, and better handle redirections (#50551)
This PR makes enhanced navigation/forms **much** more conservative: * For links, enhanced nav remains on by default, but: * It's now easy to turn it off or back on hierarchically or on a per-link basis using a `data-enhance-nav` attribute in HTML. * If the destination turns out to be a non-Blazor endpoint, enhanced nav will not apply, and the client-side JS will retry as a full page load. This is to ensure we don't confuse other pages that aren't designed to be patched into an existing page alongside the Blazor JS logic. * For posts, enhanced nav is now off by default * You turn it on by adding `data-enhance` to the form element or `Enhance` if it's an `EditForm` (they're equivalent). This is nonhierarchical since it's not desirable to enable this globally. * If the destination turns out to be a non-Blazor endpoint, enhanced nav considers this an error (same reason as for links above). But unlike with links, form posts *won't* retry since it's not safe to do that for POST requests. Developers shouldn't enhance forms that post to non-Blazor endpoints. * Redirection handling is much more comprehensive * This is way more tricky than I expected. Allowing for combinations of GET/POST, enhanced nav, streaming, Blazor and non-Blazor endpoints, internal and external redirection destinations turns out to break down into 16 separate cases, which you can find [listed in this E2E page](https://github.com/dotnet/aspnetcore/pull/50551/files#diff-477ec16959ae9dddaca3e76e1e40c4d70147210d0fde068357d9ad3e74896337). * 15 of those 16 cases now work automatically without errors. The remaining case is "form with enhanced nav does POST to a non-Blazor endpoint which redirects to an external URL", which cannot be supported without having some global server-side logic for all endpoints (not just Blazor ones), so that case explicitly throws a clear error telling the developer not to apply enhanced nav to forms that go to non-Blazor endpoints that redirect to external URLs. * There are various subtleties about making the "Back" button work correctly after these enhanced redirections, but I think all of them are covered properly now. The E2E cases all validate clicking "Back" afterwards. * Previously it was possible for an internal URL to redirect to an external URL that enabled CORS for your site, and then we would have received and merged that external content into your page. That's no longer the case since we now put `no-cors` on the fetch request, so it should now be impossible to receive content from an external origin (and it falls back on retrying as a full-page load for GET, or an error with a clear message for POST). * Disclosure of redirection URLs is now avoided. * Previously, for external or streaming redirections, we told the client-side JS code which URL to redirect to. That disclosed more info than a `fetch` normally would. As of this PR, the URL is data protected so all the client can do is navigate to an endpoint that issues a real 302 to the real URL. As such JS no longer has access to any more info than it normally would with a `fetch`. With all this, it should be much less likely for enhanced nav/redirections to run into incompatibility issues. And much easier for developers to disable it for whole regions in the UI if they want.
1 parent f4bcd4f commit 0005026

File tree

69 files changed

+899
-191
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+899
-191
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Security.Cryptography;
5+
using System.Text.Encodings.Web;
6+
using Microsoft.AspNetCore.Builder;
7+
using Microsoft.AspNetCore.DataProtection;
8+
using Microsoft.AspNetCore.Http;
9+
using Microsoft.AspNetCore.Routing;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Options;
13+
14+
namespace Microsoft.AspNetCore.Components.Endpoints;
15+
16+
internal partial class OpaqueRedirection
17+
{
18+
// During streaming SSR, a component may try to perform a redirection. Since the response has already started
19+
// this can only work if we communicate the redirection back via some command that can get handled by JS,
20+
// rather than a true 301/302/etc. But we don't want to disclose the redirection target URL to JS because that
21+
// info would not normally be available, e.g., when using 'fetch'. So we data-protect the URL and round trip
22+
// through a special endpoint that can issue a true redirection.
23+
//
24+
// The same is used during enhanced navigation if it happens to go to a Blazor endpoint that calls
25+
// NavigationManager.NavigateTo, for the same reasons.
26+
//
27+
// However, if enhanced navigation goes to a non-Blazor endpoint, the server won't do anything special and just
28+
// returns a regular 301/302/etc. To handle this,
29+
//
30+
// - If it's redirected to an internal URL, the browser will just follow the redirection automatically
31+
// and client-side code will then:
32+
// - Check if it went to a Blazor endpoint, and if so, simply update the client-side URL to match
33+
// - Or if it's a non-Blazor endpoint, behaves like "external URL" below
34+
// - If it's to an external URL:
35+
// - If it's a GET request, the client-side code will retry as a non-enhanced request
36+
// - For other request types, we have to let it fail as it would be unsafe to retry
37+
38+
private const string RedirectionDataProtectionProviderPurpose = "Microsoft.AspNetCore.Components.Endpoints.OpaqueRedirection,V1";
39+
private const string RedirectionEndpointBaseRelativeUrl = "_framework/opaque-redirect";
40+
41+
public static string CreateProtectedRedirectionUrl(HttpContext httpContext, string destinationUrl)
42+
{
43+
var protector = CreateProtector(httpContext);
44+
var options = httpContext.RequestServices.GetRequiredService<IOptions<RazorComponentsServiceOptions>>();
45+
var lifetime = options.Value.TemporaryRedirectionUrlValidityDuration;
46+
var protectedUrl = protector.Protect(destinationUrl, lifetime);
47+
return $"{RedirectionEndpointBaseRelativeUrl}?url={UrlEncoder.Default.Encode(protectedUrl)}";
48+
}
49+
50+
public static void AddBlazorOpaqueRedirectionEndpoint(IEndpointRouteBuilder endpoints)
51+
{
52+
endpoints.MapGet($"/{RedirectionEndpointBaseRelativeUrl}", httpContext =>
53+
{
54+
if (!httpContext.Request.Query.TryGetValue("url", out var protectedUrl))
55+
{
56+
httpContext.Response.StatusCode = 400;
57+
return Task.CompletedTask;
58+
}
59+
60+
var protector = CreateProtector(httpContext);
61+
string url;
62+
63+
try
64+
{
65+
url = protector.Unprotect(protectedUrl[0]!);
66+
}
67+
catch (CryptographicException ex)
68+
{
69+
if (httpContext.RequestServices.GetService<ILogger<OpaqueRedirection>>() is { } logger)
70+
{
71+
Log.OpaqueUrlUnprotectionFailed(logger, ex);
72+
}
73+
74+
httpContext.Response.StatusCode = 400;
75+
return Task.CompletedTask;
76+
}
77+
78+
httpContext.Response.Redirect(url);
79+
return Task.CompletedTask;
80+
});
81+
}
82+
83+
private static ITimeLimitedDataProtector CreateProtector(HttpContext httpContext)
84+
{
85+
var dataProtectionProvider = httpContext.RequestServices.GetRequiredService<IDataProtectionProvider>();
86+
return dataProtectionProvider.CreateProtector(RedirectionDataProtectionProviderPurpose).ToTimeLimitedDataProtector();
87+
}
88+
89+
public static partial class Log
90+
{
91+
[LoggerMessage(1, LogLevel.Information, "Opaque URL unprotection failed.", EventName = "OpaqueUrlUnprotectionFailed")]
92+
public static partial void OpaqueUrlUnprotectionFailed(ILogger<OpaqueRedirection> logger, Exception exception);
93+
}
94+
}

src/Components/Endpoints/src/Builder/RazorComponentsEndpointRouteBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public static class RazorComponentsEndpointRouteBuilderExtensions
3131

3232
EnsureRazorComponentServices(endpoints);
3333
AddBlazorWebJsEndpoint(endpoints);
34+
OpaqueRedirection.AddBlazorOpaqueRedirectionEndpoint(endpoints);
3435

3536
return GetOrCreateDataSource<TRootComponent>(endpoints).DefaultBuilder;
3637
}

src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceOptions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
1010
/// </summary>
1111
public sealed class RazorComponentsServiceOptions
1212
{
13+
// Fairly long default lifetime to allow for clock skew across servers
14+
private TimeSpan _temporaryRedirectionUrlValidityDuration = TimeSpan.FromMinutes(5);
15+
1316
internal FormDataMapperOptions _formMappingOptions = new();
1417

1518
/// <summary>
@@ -63,4 +66,20 @@ public int MaxFormMappingKeySize
6366
get => _formMappingOptions.MaxKeyBufferSize;
6467
set => _formMappingOptions.MaxKeyBufferSize = value;
6568
}
69+
70+
/// <summary>
71+
/// Gets or sets the lifetime of data protection validity for temporary redirection URLs
72+
/// emitted by Blazor server-side rendering. These are only used transiently so the lifetime
73+
/// only needs to be long enough for a client to receive the URL and begin navigation to it.
74+
/// However, it should also be long enough to allow for clock skew across servers.
75+
/// </summary>
76+
public TimeSpan TemporaryRedirectionUrlValidityDuration
77+
{
78+
get => _temporaryRedirectionUrlValidityDuration;
79+
set
80+
{
81+
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value.TotalMilliseconds, 0);
82+
_temporaryRedirectionUrlValidityDuration = value;
83+
}
84+
}
6685
}

src/Components/Endpoints/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.MaxFormM
2929
Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.MaxFormMappingRecursionDepth.get -> int
3030
Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.MaxFormMappingRecursionDepth.set -> void
3131
Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.RazorComponentsServiceOptions() -> void
32+
Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TemporaryRedirectionUrlValidityDuration.get -> System.TimeSpan
33+
Microsoft.AspNetCore.Components.Endpoints.RazorComponentsServiceOptions.TemporaryRedirectionUrlValidityDuration.set -> void
3234
Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata
3335
Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata.RootComponentMetadata(System.Type! rootComponentType) -> void
3436
Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata.Type.get -> System.Type!

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ private async Task RenderComponentCore(HttpContext context)
3535
{
3636
context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
3737
_renderer.InitializeStreamingRenderingFraming(context);
38+
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
3839

3940
var endpoint = context.GetEndpoint() ?? throw new InvalidOperationException($"An endpoint must be set on the '{nameof(HttpContext)}'.");
4041

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessed
5050
return null;
5151
}
5252

53+
public static void MarkAsAllowingEnhancedNavigation(HttpContext context)
54+
{
55+
context.Response.Headers.Add("blazor-enhanced-nav", "allow");
56+
}
57+
5358
public ValueTask<IHtmlAsyncContent> PrerenderComponentAsync(
5459
HttpContext httpContext,
5560
[DynamicallyAccessedMembers(Component)] Type componentType,
@@ -149,13 +154,15 @@ public static ValueTask<PrerenderedComponentHtmlContent> HandleNavigationExcepti
149154
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
150155
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.");
151156
}
152-
else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location) && httpContext.Request.Headers.ContainsKey("blazor-enhanced-nav"))
157+
else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location)
158+
&& IsProgressivelyEnhancedNavigation(httpContext.Request))
153159
{
154-
// It's unsafe to do a 301/302/etc to an external destination when this was requested via fetch, because
155-
// assuming it doesn't expose CORS headers, we won't be allowed to follow the redirection nor will
156-
// we even find out what the destination URL would have been. But since it's our own JS code making this
157-
// fetch request, we can have a custom protocol for describing the URL we wanted to redirect to.
158-
httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location", navigationException.Location);
160+
// For progressively-enhanced nav, we prefer to use opaque redirections for external URLs rather than
161+
// forcing the request to be retried, since that allows post-redirect-get to work, plus avoids a
162+
// duplicated request. The client can't rely on receiving this header, though, since non-Blazor endpoints
163+
// wouldn't return it.
164+
httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location",
165+
OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, navigationException.Location));
159166
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
160167
}
161168
else

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,14 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
1515

1616
internal partial class EndpointHtmlRenderer
1717
{
18-
private const string _progressivelyEnhancedNavRequestHeaderName = "blazor-enhanced-nav";
1918
private const string _streamingRenderingFramingHeaderName = "ssr-framing";
2019
private TextWriter? _streamingUpdatesWriter;
2120
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
2221
private string? _ssrFramingCommentMarkup;
2322

2423
public void InitializeStreamingRenderingFraming(HttpContext httpContext)
2524
{
26-
if (httpContext.Request.Headers.ContainsKey(_progressivelyEnhancedNavRequestHeaderName))
25+
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
2726
{
2827
var id = Guid.NewGuid().ToString();
2928
httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id);
@@ -60,7 +59,7 @@ public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilT
6059
}
6160
catch (NavigationException navigationException)
6261
{
63-
HandleNavigationAfterResponseStarted(writer, navigationException.Location);
62+
HandleNavigationAfterResponseStarted(writer, httpContext, navigationException.Location);
6463
}
6564
catch (Exception ex)
6665
{
@@ -176,10 +175,22 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,
176175
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
177176
}
178177

179-
private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl)
178+
private static void HandleNavigationAfterResponseStarted(TextWriter writer, HttpContext httpContext, string destinationUrl)
180179
{
181-
writer.Write("<blazor-ssr><template type=\"redirection\">");
182-
writer.Write(HtmlEncoder.Default.Encode(destinationUrl));
180+
writer.Write("<blazor-ssr><template type=\"redirection\"");
181+
182+
if (string.Equals(httpContext.Request.Method, "POST", StringComparison.OrdinalIgnoreCase))
183+
{
184+
writer.Write(" from=\"form-post\"");
185+
}
186+
187+
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
188+
{
189+
writer.Write(" enhanced=\"true\"");
190+
}
191+
192+
writer.Write(">");
193+
writer.Write(HtmlEncoder.Default.Encode(OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, destinationUrl)));
183194
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
184195
}
185196

@@ -243,6 +254,13 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
243254
}
244255
}
245256

257+
private static bool IsProgressivelyEnhancedNavigation(HttpRequest request)
258+
{
259+
// For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format
260+
var accept = request.Headers.Accept;
261+
return accept.Count == 1 && string.Equals(accept[0]!, "text/html;blazor-enhanced-nav=on", StringComparison.Ordinal);
262+
}
263+
246264
private readonly struct ComponentIdAndDepth
247265
{
248266
public int ComponentId { get; }

src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.Extensions.DependencyInjection;
1111
using static Microsoft.AspNetCore.Internal.LinkerFlags;
1212
using Microsoft.AspNetCore.Http.HttpResults;
13+
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
1314

1415
namespace Microsoft.AspNetCore.Components.Endpoints;
1516

@@ -46,6 +47,7 @@ private static Task RenderComponentToResponse(
4647
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
4748
{
4849
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext);
50+
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext);
4951

5052
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
5153
// backing buffers could come from a pool like they do during rendering.
@@ -55,7 +57,10 @@ private static Task RenderComponentToResponse(
5557
{ nameof(RazorComponentEndpointHost.ComponentParameters), componentParameters },
5658
});
5759

58-
await using var writer = CreateResponseWriter(httpContext.Response.Body);
60+
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
61+
var defaultBufferSize = 16 * 1024;
62+
await using var writer = new HttpResponseStreamWriter(httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
63+
using var bufferWriter = new BufferedTextWriter(writer);
5964

6065
// Note that we don't set any interactive rendering mode for the top-level output from a RazorComponentResult,
6166
// because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host
@@ -71,24 +76,17 @@ private static Task RenderComponentToResponse(
7176
// in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent
7277
// streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the
7378
// renderer sync context and cause a batch that would get missed.
74-
htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above
79+
htmlContent.WriteTo(bufferWriter, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above
7580

76-
if (!htmlContent.QuiescenceTask.IsCompleted)
81+
if (!htmlContent.QuiescenceTask.IsCompletedSuccessfully)
7782
{
78-
await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer);
83+
await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, bufferWriter);
7984
}
8085

8186
// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
8287
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
8388
// response as part of the Dispose which has a perf impact.
84-
await writer.FlushAsync();
89+
await bufferWriter.FlushAsync();
8590
});
8691
}
87-
88-
private static TextWriter CreateResponseWriter(Stream bodyStream)
89-
{
90-
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
91-
const int DefaultBufferSize = 16 * 1024;
92-
return new HttpResponseStreamWriter(bodyStream, Encoding.UTF8, DefaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
93-
}
9492
}

0 commit comments

Comments
 (0)