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 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]
|
||||||
|
|||||||
Reference in New Issue
Block a user