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:
+122
-4
@@ -327,7 +327,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.ServerStreamingServerHandler(
|
||||
new StreamAlarmsRequest(),
|
||||
new QueryActiveAlarmsRequest(),
|
||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _, _) => Task.CompletedTask));
|
||||
@@ -347,7 +347,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
||||
|
||||
await interceptor.ServerStreamingServerHandler(
|
||||
new StreamAlarmsRequest(),
|
||||
new QueryActiveAlarmsRequest(),
|
||||
streamWriter,
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
async (_, writer, _) =>
|
||||
@@ -358,6 +358,102 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Single(streamWriter.Messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-11: once a peer has exceeded the failure limit, the interceptor short-circuits with
|
||||
/// <see cref="StatusCode.ResourceExhausted"/> BEFORE calling the verifier, so an online guessing
|
||||
/// loop stops spending a store read per attempt. A verifier that always fails is used; after the
|
||||
/// limit is reached the verifier is no longer invoked.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_ExceedsFailureLimit_ShortCircuitsBeforeVerify()
|
||||
{
|
||||
CountingFailureVerifier verifier = new(Failure(ApiKeyFailure.SecretMismatch));
|
||||
ApiKeyFailureLimiter limiter = new(
|
||||
limit: 3,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 16,
|
||||
clock: TimeProvider.System);
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
verifier,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
|
||||
// The first three attempts reach the verifier and fail (recording a failure each time).
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
RpcException failure = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
Assert.Equal(StatusCode.Unauthenticated, failure.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(3, verifier.CallCount);
|
||||
|
||||
// The fourth attempt is short-circuited: ResourceExhausted, and the verifier is NOT called.
|
||||
RpcException throttled = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad-secret"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
|
||||
Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode);
|
||||
Assert.Equal(3, verifier.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-11: a successful verification resets the peer's failure counter, so accumulated failures
|
||||
/// from a fat-fingered secret do not lock out a client that subsequently authenticates.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_SuccessResetsFailureCounter()
|
||||
{
|
||||
ApiKeyFailureLimiter limiter = new(
|
||||
limit: 3,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 16,
|
||||
clock: TimeProvider.System);
|
||||
|
||||
// Two failures against the same key id, then a success (which resets), then two more
|
||||
// failures — without the reset the fifth attempt would be blocked at the limit of 3.
|
||||
GatewayGrpcAuthorizationInterceptor failing = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
GatewayGrpcAuthorizationInterceptor succeeding = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)),
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
failureLimiter: limiter);
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
await Assert.ThrowsAsync<RpcException>(
|
||||
() => failing.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
}
|
||||
|
||||
await succeeding.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_good"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply { SessionId = "s" }));
|
||||
|
||||
// Post-reset: two more failures still map to Unauthenticated (not ResourceExhausted).
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
RpcException ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => failing.UnaryServerHandler(
|
||||
new OpenSessionRequest(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_bad"),
|
||||
(_, _) => Task.FromResult(new OpenSessionReply())));
|
||||
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static MxAccessGatewayService CreateService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor identityAccessor)
|
||||
@@ -377,7 +473,8 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
private static GatewayGrpcAuthorizationInterceptor CreateInterceptor(
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey)
|
||||
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey,
|
||||
ApiKeyFailureLimiter? failureLimiter = null)
|
||||
{
|
||||
return new GatewayGrpcAuthorizationInterceptor(
|
||||
apiKeyVerifier,
|
||||
@@ -389,7 +486,12 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
{
|
||||
Mode = authenticationMode
|
||||
}
|
||||
}));
|
||||
}),
|
||||
failureLimiter ?? new ApiKeyFailureLimiter(
|
||||
limit: 1000,
|
||||
window: TimeSpan.FromMinutes(1),
|
||||
maxPeers: 1000,
|
||||
clock: TimeProvider.System));
|
||||
}
|
||||
|
||||
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
|
||||
@@ -531,6 +633,22 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingFailureVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets the number of times the verifier was invoked.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Returns the configured result and counts the invocation.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The configured verification result.</returns>
|
||||
public Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>Gets whether the verifier was called.</summary>
|
||||
|
||||
Reference in New Issue
Block a user