fix(SEC-31,SEC-32): re-partition the API-key failure limiter on (peer, key id) with probe admission
The gRPC auth failure limiter partitioned on the key id parsed out of the *unauthenticated* token and rejected with ResourceExhausted before VerifyAsync ran. Key ids are not secret — they ride in every token and are listed on the dashboard — so any network peer could send 10 garbage-secret requests per minute and deny that key indefinitely: the legitimate holder's correct secret was refused before it was ever checked, and the success-path Reset that would clear the block sat behind the verification the block prevented (SEC-31). The tracked map was also flushable — any `a_b_c`-shaped junk minted a fresh partition (the `mxgw` literal was never compared), so ~4096 throwaway tokens evicted a blocked entry and reset the window (SEC-32). ApiKeyFailureLimiter moves from IsBlocked/RecordFailure/Reset(string peer) to a partition-pair API: Check/RecordFailure/Reset(ApiKeyThrottlePartition) with an ApiKeyThrottleDecision result. Two layers share one sliding window — a composite (transport peer, key id) partition at ApiKeyFailureLimit, and a per-key-id aggregate across all peers at the new ApiKeyFailureAggregateLimit (default 30) that bounds a source-rotating sprayer. An over-limit state is now a valve rather than a wall: one request per the new ApiKeyFailureProbeIntervalSeconds (default 5) is admitted through to the real verifier, so the correct secret always reaches the constant-time compare and resets both layers. Guarantees preserved: guessing stays bounded per window, and the failure path still spends no store read per attempt. SEC-32 rides the same change set: the interceptor validates token shape (literal `mxgw` prefix, >= 3 non-empty `_` segments, key id <= 64 chars) before minting a key-id partition, each transport peer may mint at most 32 of them before the overflow collapses onto its fallback partition, and eviction prefers fully expired windows and never drops an over-limit partition below a 2x transient overshoot ceiling. Throttled attempts increment mxgateway.auth.throttled, tagged stage=peer|aggregate only — /metrics is unauthenticated (open SEC-14), so no key material may appear there. Docs in the same commit: GatewayConfiguration limiter rows plus the two new keys, the Authentication hot-path paragraph, the Authorization SEC-11 section, and the limiter / SecurityOptions XML remarks (the old NAT rationale described the defective keying). Tracking rows flipped to Done with a change-log entry. Tests: new ApiKeyFailureLimiterTests (11) covering window pruning, composite vs aggregate trip points, probe cadence, absolute-block mode, reset across both layers, junk-spray eviction resistance, the per-peer cap, and expired-window eviction preference; GatewayGrpcAuthorizationInterceptorTests gains the four SEC-31 contract tests plus NonMxgwToken_FallsBackToTransportPeerPartition (20 total); GatewayOptionsValidatorTests covers both new keys including 0 as a supported disable value (66 total).
This commit is contained in:
+66
-32
@@ -3,6 +3,7 @@ using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
// The handler pushes the gateway's constraint-bearing identity; alias away the shared library's
|
||||
@@ -16,8 +17,13 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
GatewayGrpcScopeResolver scopeResolver,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
IOptions<GatewayOptions> options,
|
||||
ApiKeyFailureLimiter failureLimiter) : Interceptor
|
||||
ApiKeyFailureLimiter failureLimiter,
|
||||
GatewayMetrics metrics) : Interceptor
|
||||
{
|
||||
// Generated key ids are far shorter; the cap only exists to stop an invented "key id" of
|
||||
// arbitrary length from becoming a limiter partition.
|
||||
private const int MaxKeyIdLength = 64;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
@@ -64,14 +70,19 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
|
||||
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
||||
|
||||
// Short-circuit a peer that has already failed too many times inside the sliding
|
||||
// window BEFORE the verification store read, so online guessing cannot spend a SQLite read
|
||||
// per attempt. The peer key prefers the presented key id over the transport address (NAT
|
||||
// caveat). ResourceExhausted signals throttling without revealing whether any particular
|
||||
// secret was valid.
|
||||
string peerKey = ResolvePeerKey(authorizationHeader, context);
|
||||
if (failureLimiter.IsBlocked(peerKey))
|
||||
// Short-circuit a partition that has already failed too many times inside the sliding window
|
||||
// BEFORE the verification store read, so online guessing cannot spend a SQLite read per
|
||||
// attempt. The partition is the composite (transport peer, key id) — never the key id alone,
|
||||
// which is public and would let any peer deny a key it does not own — plus a cross-peer
|
||||
// aggregate for the key id. An over-limit state still admits one probe per interval, so the
|
||||
// holder of the correct secret always reaches the verifier and resets the state.
|
||||
// ResourceExhausted signals throttling without revealing whether any secret was valid.
|
||||
ApiKeyThrottlePartition throttlePartition = ResolveThrottlePartition(authorizationHeader, context);
|
||||
ApiKeyThrottleDecision decision = failureLimiter.Check(throttlePartition);
|
||||
if (decision is ApiKeyThrottleDecision.ThrottledByPeer or ApiKeyThrottleDecision.ThrottledByAggregate)
|
||||
{
|
||||
metrics.RecordAuthThrottled(
|
||||
decision == ApiKeyThrottleDecision.ThrottledByPeer ? "peer" : "aggregate");
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.ResourceExhausted,
|
||||
"Too many authentication attempts. Try again later."));
|
||||
@@ -86,15 +97,17 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
|
||||
if (!verification.Succeeded || verification.Identity is null)
|
||||
{
|
||||
failureLimiter.RecordFailure(peerKey);
|
||||
failureLimiter.RecordFailure(throttlePartition);
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unauthenticated,
|
||||
"Missing or invalid API key."));
|
||||
}
|
||||
|
||||
// Successful authentication clears the peer's failure count so a legitimate client that
|
||||
// fat-fingered a few attempts is not penalised once it recovers.
|
||||
failureLimiter.Reset(peerKey);
|
||||
// Successful authentication clears both limiter layers, so a legitimate client that
|
||||
// fat-fingered a few attempts is not penalised once it recovers — and, because the check
|
||||
// above admits a probe rather than blocking absolutely, this reset stays reachable while the
|
||||
// key is under an active spray.
|
||||
failureLimiter.Reset(throttlePartition);
|
||||
|
||||
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
|
||||
|
||||
@@ -109,29 +122,50 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||
return identity;
|
||||
}
|
||||
|
||||
// Resolves the failure-limiter partition key: the presented key id (token shape
|
||||
// mxgw_<keyId>_<secret>) when the header parses, otherwise the transport peer address. Keying on
|
||||
// key id throttles a single abusive credential without locking out co-located clients behind NAT.
|
||||
private static string ResolvePeerKey(string? authorizationHeader, ServerCallContext context)
|
||||
// Resolves the failure-limiter partition: always the transport peer, plus the presented key id
|
||||
// when — and only when — the token is validly shaped. Both halves are unauthenticated input, so
|
||||
// the peer half is what keeps a throttle bound to the address that earned it.
|
||||
private static ApiKeyThrottlePartition ResolveThrottlePartition(
|
||||
string? authorizationHeader,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||
const string bearer = "Bearer ";
|
||||
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: header;
|
||||
return new ApiKeyThrottlePartition(context.Peer, TryResolveKeyId(authorizationHeader));
|
||||
}
|
||||
|
||||
// mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment. The
|
||||
// secret may itself contain underscores, but the key id is unaffected.
|
||||
string tokenText = token.ToString();
|
||||
string[] parts = tokenText.Split('_');
|
||||
if (parts.Length >= 3 && parts[0].Length > 0 && parts[1].Length > 0)
|
||||
{
|
||||
return "key:" + parts[1];
|
||||
}
|
||||
// Token shape mxgw_<keyId>_<secret>: the key id is the second underscore-delimited segment (the
|
||||
// secret may itself contain underscores, which does not affect the key id). The shape is checked
|
||||
// before a key-id partition is minted so a spray of invented tokens cannot mint one tracked
|
||||
// partition each and flush the limiter's bounded map (SEC-32). Anything that fails the check
|
||||
// falls back to the sender's transport-peer partition.
|
||||
private static string? TryResolveKeyId(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return "peer:" + context.Peer;
|
||||
ReadOnlySpan<char> header = authorizationHeader.AsSpan().Trim();
|
||||
const string bearer = "Bearer ";
|
||||
ReadOnlySpan<char> token = header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
|
||||
? header[bearer.Length..].Trim()
|
||||
: header;
|
||||
|
||||
string[] parts = token.ToString().Split('_');
|
||||
if (parts.Length < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[0], AuthStoreServiceCollectionExtensions.TokenPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parts[1].Length == 0 || parts[1].Length > MaxKeyIdLength || parts[2].Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user