fix(archreview): security authz+hub cluster (SEC-07/05/08/11) + validation hardening
SEC-07: add QueryActiveAlarmsRequest -> events:read scope arm; fix two tests that
constructed StreamAlarmsRequest instead of QueryActiveAlarmsRequest.
SEC-05: shorten hub-token lifetime 30m -> 5m; document that the ?access_token= query
carriage must never be request-logged.
SEC-08: gateway-side CachingApiKeyVerifier (short TTL, keyed on a hash of the presented
secret) skips the per-call store read+last_used write; CoalescingMarkApiKeyStore bounds
last_used writes to <=1/key/min; identity constraints are cached. Invalidation is wired
at the gateway admin sites (revoke/rotate/delete); short TTL backstops out-of-process CLI.
SEC-11: fixed-window rate limit on POST /auth/login + a per-peer (key-id) failure limiter
checked before VerifyAsync; new MxGateway:Security options bound + validated.
Also fixes regressions from the SEC-01/04/06 commit (c185f62) that a narrow test filter
missed (all now covered by a full-suite checkpoint):
- Rooting check is cross-platform: accepts Windows C:\/UNC forms on Unix so the shipped
appsettings path validates on the macOS dev box, still rejecting bare filenames.
- AddGatewayConfiguration TryAdds a non-production IHostEnvironment fallback so the validator
resolves in minimal test/tooling containers; the real host + apikey CLI register the actual
environment first (TryAdd no-op there).
- Test assembly defaults ASPNETCORE_ENVIRONMENT=Development (ModuleInitializer) so full-host
tests exercise wiring instead of tripping the SEC-04/06 production guards.
- GatewayOptionsTests asserts the SEC-01 CommonApplicationData-derived default (platform-correct).
archreview: SEC-07/05/08/11 (P1). Verified: NonWindows build clean; full gateway suite
747 passed / 42 failed, where all 42 are the pre-existing macOS named-pipe-harness failures
(Unix-socket path limit) and 0 are validation/regression failures.
This commit is contained in:
@@ -15,7 +15,8 @@ public sealed class DashboardApiKeyManagementService(
|
||||
ApiKeyAdminCommands adminCommands,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IAuditWriter auditWriter,
|
||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IApiKeyCacheInvalidator? cacheInvalidator = null) : IDashboardApiKeyManagementService
|
||||
{
|
||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||
@@ -100,6 +101,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: drop any cached verification for this key in THIS gateway process so the revoke
|
||||
// takes effect immediately rather than after the verification-cache TTL elapses.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
user,
|
||||
normalizedKeyId,
|
||||
@@ -138,6 +143,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: the old secret's cached verification must not outlive the rotate — evict it so
|
||||
// the superseded secret stops authenticating within this process immediately.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
bool succeeded = rotated.Token is not null;
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
@@ -182,6 +191,10 @@ public sealed class DashboardApiKeyManagementService(
|
||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// SEC-08: a deleted key is only reachable after a prior revoke, but evict defensively so no
|
||||
// cached verification survives the delete.
|
||||
cacheInvalidator?.Invalidate(normalizedKeyId);
|
||||
|
||||
await WriteDashboardAuditAsync(
|
||||
user,
|
||||
normalizedKeyId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -10,6 +11,41 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
||||
public static class DashboardEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route (SEC-11). Registered
|
||||
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
|
||||
/// </summary>
|
||||
internal const string LoginRateLimiterPolicy = "dashboard-login";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the fixed-window rate-limit partition for a login request, keyed on the remote IP.
|
||||
/// Shared by the production policy registration and the tests so both exercise identical limits.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The inbound request.</param>
|
||||
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
|
||||
/// <returns>The per-IP fixed-window partition.</returns>
|
||||
internal static RateLimitPartition<string> GetLoginRateLimitPartition(
|
||||
HttpContext httpContext,
|
||||
SecurityOptions security)
|
||||
{
|
||||
string partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey,
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = security.LoginRateLimitPermitLimit,
|
||||
Window = TimeSpan.FromSeconds(security.LoginRateLimitWindowSeconds),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Test seam: a standalone partitioned limiter over the production partition logic, so a test can
|
||||
// acquire leases and assert the (permit+1)th is rejected without an HTTP server.
|
||||
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
|
||||
=> PartitionedRateLimiter.Create<HttpContext, string>(
|
||||
ctx => GetLoginRateLimitPartition(ctx, security));
|
||||
|
||||
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The route builder for chaining.</returns>
|
||||
@@ -40,6 +76,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||
.AllowAnonymous()
|
||||
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
|
||||
.RequireRateLimiting(LoginRateLimiterPolicy)
|
||||
.WithName("DashboardLoginPost");
|
||||
|
||||
endpoints.MapPost(
|
||||
|
||||
@@ -10,6 +10,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// string (used by WebSocket upgrade requests that can't carry custom headers)
|
||||
/// and validating it via <see cref="HubTokenService"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carrying the token in the <c>?access_token=</c> query string is the standard SignalR pattern:
|
||||
/// the WebSocket upgrade handshake cannot attach a custom <c>Authorization</c> header, so the JS
|
||||
/// client appends the token to the URL. Because a query string is far easier to capture than a
|
||||
/// header (proxy access logs, browser history, <c>Referer</c>), this value must NEVER be
|
||||
/// request-logged. Serilog HTTP request logging is intentionally not enabled; if it is ever added,
|
||||
/// scrub the <c>access_token</c> query parameter before the URL reaches a sink. The short token
|
||||
/// lifetime (<see cref="HubTokenService.TokenLifetime"/>) is the backstop that bounds exposure of a
|
||||
/// leaked token.
|
||||
/// </remarks>
|
||||
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly HubTokenService _tokens;
|
||||
|
||||
@@ -26,7 +26,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
public sealed class HubTokenService
|
||||
{
|
||||
private const string ProtectorPurpose = "ZB.MOM.WW.MxGateway.Dashboard.HubToken.v1";
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
|
||||
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy
|
||||
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
|
||||
// bounds how long a stale role set survives a role change. Five minutes is transparent to
|
||||
// clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect;
|
||||
// see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately
|
||||
// deferred until per-session hub ACLs land (SEC-25), when tokens gain session binding.
|
||||
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
|
||||
@@ -41,14 +49,24 @@ public sealed class HubTokenService
|
||||
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
||||
/// <param name="user">The claims principal representing the user.</param>
|
||||
/// <returns>The data-protected bearer token string.</returns>
|
||||
public string Issue(ClaimsPrincipal user)
|
||||
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
|
||||
|
||||
/// <summary>
|
||||
/// Issues a bearer token that expires after the supplied lifetime. Test seam so a caller can
|
||||
/// mint an already-expired token deterministically without wall-clock delay; production callers
|
||||
/// use <see cref="Issue(ClaimsPrincipal)"/> and get <see cref="TokenLifetime"/>.
|
||||
/// </summary>
|
||||
/// <param name="user">The claims principal representing the user.</param>
|
||||
/// <param name="lifetime">The lifetime applied to the token; a non-positive value yields an already-expired token.</param>
|
||||
/// <returns>The data-protected bearer token string.</returns>
|
||||
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
HubTokenPayload payload = new(
|
||||
user.Identity?.Name,
|
||||
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]);
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
|
||||
}
|
||||
|
||||
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
||||
|
||||
Reference in New Issue
Block a user