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:
Joseph Doherty
2026-07-09 07:31:35 -04:00
parent 970613eebd
commit 17f16ea181
29 changed files with 1521 additions and 18 deletions
@@ -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(