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:
@@ -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<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 =
|
||||
[
|
||||
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(
|
||||
|
||||
@@ -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<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
.AddInteractiveServerRenderMode()
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
@@ -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<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]
|
||||
public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess()
|
||||
public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app)
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith(
|
||||
"/dashboard",
|
||||
StringComparison.Ordinal) == true)
|
||||
.ToArray();
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.NotEmpty(endpoints);
|
||||
Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null);
|
||||
string[] anonymousEndpointNames =
|
||||
["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();
|
||||
|
||||
Assert.NotEmpty(matches);
|
||||
Assert.All(matches, endpoint =>
|
||||
{
|
||||
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal(DashboardAuthenticationDefaults.AuthorizationPolicy, authorize.Policy);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user