From a8aafdf974f9dde06b4d0ee5099e755b21676cd5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 16:45:29 -0400 Subject: [PATCH] Enforce dashboard authorization on all component routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes code-review findings Server-001 (Critical) and Server-003 (High). Server-001: the dashboard Razor components were mapped with no authorization policy, so every dashboard page — including the API Keys page — was reachable unauthenticated. MapRazorComponents() now requires DashboardAuthenticationDefaults.AuthorizationPolicy; unauthenticated requests are challenged by the cookie scheme and redirected to the login page. Server-003: DashboardAuthenticator.CreatePrincipal never issued the 'scope' claim that DashboardAuthorizationHandler checks when Dashboard:RequireAdminScope is enabled, so enforcing the policy would have denied every LDAP login. CreatePrincipal (reached only after the required-group check passes) now emits the admin scope claim. Replaces the GatewayApplicationTests case that asserted dashboard routes allow anonymous access — it encoded the bug as expected behavior — with tests that verify component routes require the policy and the login/logout/denied endpoints allow anonymous. All 309 MxGateway.Tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Dashboard/DashboardAuthenticator.cs | 9 +++- ...DashboardEndpointRouteBuilderExtensions.cs | 7 ++- .../Gateway/GatewayApplicationTests.cs | 48 +++++++++++++++---- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs index f985ba9..ff96a43 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using System.Text; using Microsoft.Extensions.Options; using MxGateway.Server.Configuration; +using MxGateway.Server.Security.Authorization; using Novell.Directory.Ldap; namespace MxGateway.Server.Dashboard; @@ -238,10 +239,16 @@ public sealed class DashboardAuthenticator( string displayName, IEnumerable groups) { + // CreatePrincipal is reached only after IsMemberOfRequiredGroup passed, + // so the authenticated user is authorized for the dashboard. Emit the + // admin scope claim that DashboardAuthorizationHandler checks when + // Dashboard:RequireAdminScope is enabled — without it, every LDAP login + // would be denied once route-level authorization is enforced. List claims = [ new Claim(ClaimTypes.NameIdentifier, username), - new Claim(ClaimTypes.Name, displayName) + new Claim(ClaimTypes.Name, displayName), + new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin) ]; claims.AddRange(groups.Select(group => new Claim( diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index e9ff533..2539dfb 100644 --- a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -52,8 +52,13 @@ public static class DashboardEndpointRouteBuilderExtensions .AllowAnonymous() .WithName("DashboardAccessDenied"); + // Every dashboard Razor component requires an authorized session. The + // login/logout/denied endpoints above opt out via AllowAnonymous(); an + // unauthenticated request to a component route is challenged by the + // cookie scheme and redirected to the login page. dashboard.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy); return endpoints; } diff --git a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs index 5bf3710..91f98bc 100644 --- a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MxGateway.Server; +using MxGateway.Server.Dashboard; using MxGateway.Server.Metrics; namespace MxGateway.Tests.Gateway; @@ -54,19 +55,48 @@ public sealed class GatewayApplicationTests endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); } - /// Verifies that Build does not map dashboard routes when the dashboard is disabled. + /// Verifies that the dashboard login, logout, and denied endpoints allow anonymous access. [Fact] - public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess() + public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess() { WebApplication app = GatewayApplication.Build([]); - IReadOnlyList endpoints = GetRouteEndpoints(app) - .Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith( - "/dashboard", - StringComparison.Ordinal) == true) - .ToArray(); + IReadOnlyList endpoints = GetRouteEndpoints(app); - Assert.NotEmpty(endpoints); - Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata() is not null); + string[] anonymousEndpointNames = + ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardAccessDenied"]; + foreach (string endpointName in anonymousEndpointNames) + { + RouteEndpoint endpoint = Assert.Single( + endpoints, + candidate => candidate.Metadata.GetMetadata()?.EndpointName == endpointName); + + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + } + + /// Verifies that dashboard Razor component routes require the dashboard authorization policy. + [Fact] + public void Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization() + { + WebApplication app = GatewayApplication.Build([]); + IReadOnlyList endpoints = GetRouteEndpoints(app); + + string[] componentRoutes = + ["/dashboard/", "/dashboard/sessions", "/dashboard/workers", "/dashboard/events", "/dashboard/settings"]; + foreach (string route in componentRoutes) + { + RouteEndpoint[] matches = endpoints + .Where(endpoint => endpoint.RoutePattern.RawText == route) + .ToArray(); + + Assert.NotEmpty(matches); + Assert.All(matches, endpoint => + { + IAuthorizeData? authorize = endpoint.Metadata.GetMetadata(); + Assert.NotNull(authorize); + Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy); + }); + } } [Fact]