Enforce dashboard authorization on all component routes

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<App>() 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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 16:45:29 -04:00
parent 3cc53a8c69
commit a8aafdf974
3 changed files with 53 additions and 11 deletions
@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Text; using System.Text;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration; using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap; using Novell.Directory.Ldap;
namespace MxGateway.Server.Dashboard; namespace MxGateway.Server.Dashboard;
@@ -238,10 +239,16 @@ public sealed class DashboardAuthenticator(
string displayName, string displayName,
IEnumerable<string> groups) IEnumerable<string> 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<Claim> claims = List<Claim> claims =
[ [
new Claim(ClaimTypes.NameIdentifier, username), 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( claims.AddRange(groups.Select(group => new Claim(
@@ -52,8 +52,13 @@ public static class DashboardEndpointRouteBuilderExtensions
.AllowAnonymous() .AllowAnonymous()
.WithName("DashboardAccessDenied"); .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<App>() dashboard.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
return endpoints; return endpoints;
} }
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MxGateway.Server; using MxGateway.Server;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Metrics; using MxGateway.Server.Metrics;
namespace MxGateway.Tests.Gateway; namespace MxGateway.Tests.Gateway;
@@ -54,19 +55,48 @@ public sealed class GatewayApplicationTests
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout"); endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
} }
/// <summary>Verifies that Build does not map dashboard routes when the dashboard is disabled.</summary> /// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
[Fact] [Fact]
public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess() public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
{ {
WebApplication app = GatewayApplication.Build([]); WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app) IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
.Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith(
"/dashboard", string[] anonymousEndpointNames =
StringComparison.Ordinal) == true) ["DashboardLogin", "DashboardLoginPost", "DashboardLogout", "DashboardAccessDenied"];
foreach (string endpointName in anonymousEndpointNames)
{
RouteEndpoint endpoint = Assert.Single(
endpoints,
candidate => candidate.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == endpointName);
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
}
}
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
[Fact]
public void Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
{
WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> 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(); .ToArray();
Assert.NotEmpty(endpoints); Assert.NotEmpty(matches);
Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null); Assert.All(matches, endpoint =>
{
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
Assert.NotNull(authorize);
Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy);
});
}
} }
[Fact] [Fact]