From 73e3d67ca103048c85d099bcff6033016e0d39de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 23:13:40 +0000 Subject: [PATCH 1/7] Initial plan From 1eaaede6fda97482c062f8c03456c4786314b5df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 23:34:12 +0000 Subject: [PATCH 2/7] Add ForbiddenPage support for Blazor Router and SSR endpoints Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/src/NavigationManager.cs | 37 +++++++++ .../Components/src/PublicAPI.Unshipped.txt | 8 ++ .../src/Routing/ForbiddenEventArgs.cs | 15 ++++ .../Components/src/Routing/Router.cs | 78 +++++++++++++++++++ .../src/RazorComponentEndpointInvoker.cs | 20 +++++ .../EndpointHtmlRenderer.EventDispatch.cs | 45 +++++++++++ .../EndpointHtmlRenderer.Streaming.cs | 6 ++ .../src/Rendering/EndpointHtmlRenderer.cs | 3 + 8 files changed, 212 insertions(+) create mode 100644 src/Components/Components/src/Routing/ForbiddenEventArgs.cs diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index fecbfaca6c28..4fe7b4b3bbd4 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -54,6 +54,25 @@ public event EventHandler OnNotFound private EventHandler? _notFound; + /// + /// An event that fires when access to a page is forbidden. + /// + public event EventHandler OnForbidden + { + add + { + AssertInitialized(); + _forbidden += value; + } + remove + { + AssertInitialized(); + _forbidden -= value; + } + } + + private EventHandler? _forbidden; + // For the baseUri it's worth storing as a System.Uri so we can do operations // on that type. System.Uri gives us access to the original string anyway. private Uri? _baseUri; @@ -214,6 +233,24 @@ private void NotFoundCore() } } + /// + /// Handles setting the Forbidden state. + /// + public void Forbidden() => ForbiddenCore(); + + private void ForbiddenCore() + { + if (_forbidden is null) + { + // global router doesn't exist, no events were registered + return; + } + else + { + _forbidden.Invoke(this, new ForbiddenEventArgs()); + } + } + /// /// Called to initialize BaseURI and current URI before these values are used for the first time. /// Override and call this method to dynamically calculate these values. diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..76decbd60bb6 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #nullable enable +Microsoft.AspNetCore.Components.NavigationManager.Forbidden() -> void +Microsoft.AspNetCore.Components.NavigationManager.OnForbidden -> System.EventHandler! +Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs +Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.ForbiddenEventArgs() -> void +Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.Path.get -> string? +Microsoft.AspNetCore.Components.Routing.ForbiddenEventArgs.Path.set -> void +Microsoft.AspNetCore.Components.Routing.Router.ForbiddenPage.get -> System.Type? +Microsoft.AspNetCore.Components.Routing.Router.ForbiddenPage.set -> void diff --git a/src/Components/Components/src/Routing/ForbiddenEventArgs.cs b/src/Components/Components/src/Routing/ForbiddenEventArgs.cs new file mode 100644 index 000000000000..5f532dfcea4b --- /dev/null +++ b/src/Components/Components/src/Routing/ForbiddenEventArgs.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Routing; + +/// +/// for . +/// +public sealed class ForbiddenEventArgs : EventArgs +{ + /// + /// Gets the path of ForbiddenPage. If the path is set, it indicates that a subscriber has handled the rendering of the Forbidden contents. + /// + public string? Path { get; set; } +} diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index ecb69fe2cf63..128db4c6ed47 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -30,6 +30,7 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary bool _navigationInterceptionEnabled; ILogger _logger; string _notFoundPageRoute; + string _forbiddenPageRoute; private string _updateScrollPositionForHashLastLocation; private bool _updateScrollPositionForHash; @@ -81,6 +82,13 @@ static readonly IReadOnlyDictionary _emptyParametersDictionary [DynamicallyAccessedMembers(LinkerFlags.Component)] public Type? NotFoundPage { get; set; } + /// + /// Gets or sets the page content to display when access to a page is forbidden. + /// + [Parameter] + [DynamicallyAccessedMembers(LinkerFlags.Component)] + public Type? ForbiddenPage { get; set; } + /// /// Gets or sets the content to display when a match is found for the requested route. /// @@ -117,6 +125,7 @@ public void Attach(RenderHandle renderHandle) _locationAbsolute = NavigationManager.Uri; NavigationManager.LocationChanged += OnLocationChanged; NavigationManager.OnNotFound += OnNotFound; + NavigationManager.OnForbidden += OnForbidden; RoutingStateProvider = ServiceProvider.GetService(); if (HotReloadManager.Default.MetadataUpdateSupported) @@ -171,6 +180,28 @@ public async Task SetParametersAsync(ParameterView parameters) } } + if (ForbiddenPage != null) + { + if (!typeof(IComponent).IsAssignableFrom(ForbiddenPage)) + { + throw new InvalidOperationException($"The type {ForbiddenPage.FullName} " + + $"does not implement {typeof(IComponent).FullName}."); + } + + var routeAttributes = ForbiddenPage.GetCustomAttributes(typeof(RouteAttribute), inherit: true); + if (routeAttributes.Length == 0) + { + throw new InvalidOperationException($"The type {ForbiddenPage.FullName} " + + $"does not have a {typeof(RouteAttribute).FullName} applied to it."); + } + + var routeAttribute = (RouteAttribute)routeAttributes[0]; + if (routeAttribute.Template != null) + { + _forbiddenPageRoute = routeAttribute.Template; + } + } + if (!_onNavigateCalled) { _onNavigateCalled = true; @@ -187,6 +218,7 @@ public void Dispose() { NavigationManager.LocationChanged -= OnLocationChanged; NavigationManager.OnNotFound -= OnNotFound; + NavigationManager.OnForbidden -= OnForbidden; if (HotReloadManager.Default.MetadataUpdateSupported) { HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches; @@ -409,6 +441,26 @@ private void OnNotFound(object sender, NotFoundEventArgs args) } } + private void OnForbidden(object sender, ForbiddenEventArgs args) + { + bool renderContentIsProvided = ForbiddenPage != null || args.Path != null; + if (_renderHandle.IsInitialized && renderContentIsProvided) + { + if (!string.IsNullOrEmpty(args.Path)) + { + // The path can be set by a subscriber not defined in blazor framework. + _renderHandle.Render(builder => RenderComponentByRoute(builder, args.Path)); + } + else + { + // Having the path set signals to the endpoint renderer that router handled rendering. + args.Path = _forbiddenPageRoute; + RenderForbidden(); + } + Log.DisplayingForbidden(_logger, args.Path); + } + } + internal void RenderComponentByRoute(RenderTreeBuilder builder, string route) { var componentType = FindComponentTypeByRoute(route); @@ -466,6 +518,29 @@ private void RenderNotFound() }); } + private void RenderForbidden() + { + _renderHandle.Render(builder => + { + if (ForbiddenPage != null) + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(RouteView.RouteData), + new RouteData(ForbiddenPage, _emptyParametersDictionary)); + builder.CloseComponent(); + } + else + { + DefaultForbiddenContent(builder); + } + }); + } + + private static void DefaultForbiddenContent(RenderTreeBuilder builder) + { + builder.AddContent(0, "Forbidden"); + } + async Task IHandleAfterRender.OnAfterRenderAsync() { if (!_navigationInterceptionEnabled) @@ -495,6 +570,9 @@ private static partial class Log [LoggerMessage(4, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on request", EventName = "DisplayingNotFoundOnRequest")] internal static partial void DisplayingNotFound(ILogger logger, string displayedContentPath); + + [LoggerMessage(5, LogLevel.Debug, $"Displaying contents of {{displayedContentPath}} on forbidden request", EventName = "DisplayingForbiddenOnRequest")] + internal static partial void DisplayingForbidden(ILogger logger, string displayedContentPath); #pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs index 22119d0522c9..28316b4b9355 100644 --- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs +++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs @@ -135,6 +135,11 @@ await _renderer.InitializeStandardComponentServicesAsync( _renderer.SetNotFoundWhenResponseNotStarted(); } + if (_renderer.ForbiddenEventArgs != null) + { + _renderer.SetForbiddenWhenResponseNotStarted(); + } + if (!quiesceTask.IsCompleted) { // An incomplete QuiescenceTask indicates there may be streaming rendering updates. @@ -167,6 +172,10 @@ await _renderer.InitializeStandardComponentServicesAsync( { await _renderer.SetNotFoundWhenResponseHasStarted(); } + if (_renderer.ForbiddenEventArgs != null) + { + await _renderer.SetForbiddenWhenResponseHasStarted(); + } } else { @@ -191,6 +200,17 @@ await _renderer.InitializeStandardComponentServicesAsync( return; } + if (context.Response.StatusCode == StatusCodes.Status403Forbidden && + !isReExecuted && + string.IsNullOrEmpty(_renderer.ForbiddenEventArgs?.Path)) + { + // Router did not handle the Forbidden event, otherwise this would not be empty. + // Don't flush the response if we have an unhandled 403 rendering + // This will allow the StatusCodePages middleware to re-execute the request + context.Response.ContentType = null; + return; + } + // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying // response asynchronously. In the absence of this line, the buffer gets synchronously written to the // response as part of the Dispose which has a perf impact. diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index a06d3e66d622..5605ea0e033c 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -108,6 +108,35 @@ internal async Task SetNotFoundWhenResponseHasStarted() SignalRendererToFinishRendering(); } + internal void SetForbiddenWhenResponseNotStarted() + { + _httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + + // When the application triggers a Forbidden event, we continue rendering the current batch. + // However, after completing this batch, we do not want to process any further UI updates, + // as we are going to return a 403 status and discard the UI updates generated so far. + SignalRendererToFinishRendering(); + } + + internal async Task SetForbiddenWhenResponseHasStarted() + { + if (string.IsNullOrEmpty(_forbiddenUrl)) + { + var baseUri = $"{_httpContext.Request.Scheme}://{_httpContext.Request.Host}{_httpContext.Request.PathBase}/"; + _forbiddenUrl = GetForbiddenUrl(baseUri, ForbiddenEventArgs); + } + var defaultBufferSize = 16 * 1024; + await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + using var bufferWriter = new BufferedTextWriter(writer); + HandleForbiddenAfterResponseStarted(bufferWriter, _httpContext, _forbiddenUrl); + await bufferWriter.FlushAsync(); + + // When the application triggers a Forbidden event, we continue rendering the current batch. + // However, after completing this batch, we do not want to process any further UI updates, + // as we are going to return a 403 status and discard the UI updates generated so far. + SignalRendererToFinishRendering(); + } + private string GetNotFoundUrl(string baseUri, NotFoundEventArgs? args) { string? path = args?.Path; @@ -124,6 +153,22 @@ private string GetNotFoundUrl(string baseUri, NotFoundEventArgs? args) return $"{baseUri}{path.TrimStart('/')}"; } + private string GetForbiddenUrl(string baseUri, ForbiddenEventArgs? args) + { + string? path = args?.Path; + if (string.IsNullOrEmpty(path)) + { + var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string; + if (string.IsNullOrEmpty(pathFormat)) + { + throw new InvalidOperationException($"The {nameof(Router.ForbiddenPage)} route must be specified or re-execution middleware has to be set to render forbidden content."); + } + + path = pathFormat; + } + return $"{baseUri}{path.TrimStart('/')}"; + } + private async Task OnNavigateTo(string uri) { if (_httpContext.Response.HasStarted) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index 50728a8c3271..4c9174917696 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -231,6 +231,12 @@ private static void HandleNotFoundAfterResponseStarted(TextWriter writer, HttpCo WriteResponseTemplate(writer, httpContext, notFoundUrl, useEnhancedNav: true); } + private static void HandleForbiddenAfterResponseStarted(TextWriter writer, HttpContext httpContext, string forbiddenUrl) + { + writer.Write("