fix(SEC-38,SEC-39): preserve aggregate and fallback throttles

This commit is contained in:
Joseph Doherty
2026-08-25 11:00:29 -04:00
parent 780720165a
commit 1875426e4b
3 changed files with 72 additions and 10 deletions
+3 -1
View File
@@ -138,7 +138,9 @@ would otherwise let any peer throttle a key it does not hold — and it joins th
partition only when the presented token is validly shaped partition only when the presented token is validly shaped
(`mxgw_<keyId>_<secret>`, key id at most 64 characters), with at most 32 key-id (`mxgw_<keyId>_<secret>`, key id at most 64 characters), with at most 32 key-id
partitions per address before the overflow collapses onto that address's fallback partitions per address before the overflow collapses onto that address's fallback
partition. An over-limit state admits one probe per partition. That peer-layer collapse does not exempt a validly shaped key id from
the cross-peer aggregate: its failures still feed and consult the key-id aggregate,
whose own cardinality is bounded by the configured LRU cap. An over-limit state admits one probe per
`ApiKeyFailureProbeIntervalSeconds` through to the real verifier and refuses `ApiKeyFailureProbeIntervalSeconds` through to the real verifier and refuses
everything else with `ResourceExhausted` before the store read, so a legitimate everything else with `ResourceExhausted` before the store read, so a legitimate
holder presenting the correct secret always reaches the constant-time compare and holder presenting the correct secret always reaches the constant-time compare and
@@ -28,8 +28,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
/// <para> /// <para>
/// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>). /// The tracked partition set is a bounded LRU (<see cref="SecurityOptions.ApiKeyFailureTrackedPeers"/>).
/// Because only a validly shaped token mints a key-id partition and each transport peer may mint at /// Because only a validly shaped token mints a key-id partition and each transport peer may mint at
/// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the overflow collapses onto that address's /// most <see cref="MaxKeyIdPartitionsPerPeer"/> of them (the peer-layer overflow collapses onto that
/// fallback partition), the map bounds memory <em>and</em> resists a junk-token flush (SEC-32): /// address's fallback partition), the map bounds memory <em>and</em> resists a junk-token flush
/// (SEC-32). A validly shaped presented key id still feeds its cross-peer aggregate when its peer
/// layer uses the fallback, so exhausting the per-peer cap cannot bypass distributed-spray
/// protection; the aggregate map remains bounded by the same capped eviction policy:
/// eviction prefers fully expired windows, never removes an over-limit partition below a transient /// eviction prefers fully expired windows, never removes an over-limit partition below a transient
/// overshoot ceiling of twice the cap, and only then falls back to least-recently-active. Successful /// overshoot ceiling of twice the cap, and only then falls back to least-recently-active. Successful
/// peers are removed on reset, so in steady state the map holds only partitions with recent failures /// peers are removed on reset, so in steady state the map holds only partitions with recent failures
@@ -155,9 +158,9 @@ public sealed class ApiKeyFailureLimiter
WindowState? peerState = _partitions.TryGetValue(partitionKey, out WindowState? tracked) ? tracked : null; WindowState? peerState = _partitions.TryGetValue(partitionKey, out WindowState? tracked) ? tracked : null;
WindowState? aggregateState = null; WindowState? aggregateState = null;
if (effectiveKeyId is not null if (partition.KeyId is not null
&& _aggregateLimit > 0 && _aggregateLimit > 0
&& _aggregates.TryGetValue(effectiveKeyId, out WindowState? aggregate)) && _aggregates.TryGetValue(partition.KeyId, out WindowState? aggregate))
{ {
aggregateState = aggregate; aggregateState = aggregate;
} }
@@ -222,11 +225,13 @@ public sealed class ApiKeyFailureLimiter
(Peer: peer, KeyId: effectiveKeyId)); (Peer: peer, KeyId: effectiveKeyId));
RecordInto(state, now, _limit); RecordInto(state, now, _limit);
// Only a key id that earned its own partition feeds the aggregate: an id squeezed out by the // The aggregate follows the validly shaped presented key id independently of the peer
// per-peer cap is spray, and letting it through would make aggregate cardinality unbounded. // layer's storage choice. Otherwise an attacker could exhaust each peer's dedicated-key cap
if (effectiveKeyId is not null && _aggregateLimit > 0) // and route a victim id through the fallback to bypass cross-peer throttling. Aggregate
// cardinality is still bounded by EvictIfOverCapacity below.
if (partition.KeyId is not null && _aggregateLimit > 0)
{ {
WindowState aggregate = _aggregates.GetOrAdd(effectiveKeyId, static _ => new WindowState(null, null)); WindowState aggregate = _aggregates.GetOrAdd(partition.KeyId, static _ => new WindowState(null, null));
RecordInto(aggregate, now, _aggregateLimit); RecordInto(aggregate, now, _aggregateLimit);
} }
@@ -261,7 +266,8 @@ public sealed class ApiKeyFailureLimiter
// contributed by other key ids — and by junk-shaped tokens — from the same address, so // contributed by other key ids — and by junk-shaped tokens — from the same address, so
// removing it would let one successful authentication wipe an in-progress spray. The shared // removing it would let one successful authentication wipe an in-progress spray. The shared
// bucket decays by window expiry instead; the caller still recovers through probe admission. // bucket decays by window expiry instead; the caller still recovers through probe admission.
if (string.Equals(effectiveKeyId, partition.KeyId, StringComparison.Ordinal)) if (effectiveKeyId is not null
&& string.Equals(effectiveKeyId, partition.KeyId, StringComparison.Ordinal))
{ {
RemovePartition(partitionKey); RemovePartition(partitionKey);
} }
@@ -101,6 +101,37 @@ public sealed class ApiKeyFailureLimiterTests
limiter.Check(new ApiKeyThrottlePartition("ipv4:10.0.9.9:1", "other-key"))); limiter.Check(new ApiKeyThrottlePartition("ipv4:10.0.9.9:1", "other-key")));
} }
/// <summary>
/// SEC-38: a valid key id still feeds and consults its cross-peer aggregate when each
/// attacking peer has exhausted its 32 dedicated key-id partitions and therefore records
/// the peer layer through the fallback bucket.
/// </summary>
[Fact]
public void AggregateLayer_TripsForValidKeyIdCollapsedToPeerFallback()
{
ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch);
ApiKeyFailureLimiter limiter = CreateLimiter(
clock,
limit: 100,
aggregateLimit: 3,
maxPartitions: 4096);
for (int peerIndex = 0; peerIndex < 3; peerIndex++)
{
string peer = $"ipv4:10.0.0.{peerIndex + 1}:1";
for (int keyIndex = 0; keyIndex < ApiKeyFailureLimiter.MaxKeyIdPartitionsPerPeer; keyIndex++)
{
limiter.RecordFailure(new ApiKeyThrottlePartition(peer, $"prefill-{peerIndex}-{keyIndex}"));
}
limiter.RecordFailure(new ApiKeyThrottlePartition(peer, "victim"));
}
Assert.Equal(
ApiKeyThrottleDecision.ThrottledByAggregate,
limiter.Check(new ApiKeyThrottlePartition("ipv4:10.0.9.9:1", "victim")));
}
/// <summary> /// <summary>
/// A throttled partition is a valve, not a wall: exactly one request per probe interval is /// A throttled partition is a valve, not a wall: exactly one request per probe interval is
/// admitted to the real verifier, so the holder of the correct secret can always get through. /// admitted to the real verifier, so the holder of the correct secret can always get through.
@@ -340,6 +371,29 @@ public sealed class ApiKeyFailureLimiterTests
Assert.Equal(ApiKeyThrottleDecision.ThrottledByPeer, limiter.Check(overCap)); Assert.Equal(ApiKeyThrottleDecision.ThrottledByPeer, limiter.Check(overCap));
} }
/// <summary>
/// SEC-39: a request with no effective key id does not own the peer's shared fallback
/// partition. Resetting after such a request must leave other fallback failures tracked.
/// </summary>
[Fact]
public void Reset_WithNullEffectiveKeyId_DoesNotClearSharedFallbackPartition()
{
ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch);
ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 3, aggregateLimit: 100);
ApiKeyThrottlePartition fallback = new("ipv4:10.0.0.1:1", KeyId: null);
RecordFailures(limiter, fallback, 3);
Assert.Equal(
ApiKeyThrottleDecision.ThrottledByPeer,
limiter.Check(fallback, out ApiKeyFailureLimiter.PartitionResolution resolution));
Assert.Null(resolution.EffectiveKeyId);
limiter.Reset(fallback, resolution);
Assert.True(limiter.IsTracked(fallback));
Assert.Equal(ApiKeyThrottleDecision.ThrottledByPeer, limiter.Check(fallback));
}
/// <summary> /// <summary>
/// SEC-32: a spray of unique junk partitions (junk tokens resolve to the sender's fallback /// SEC-32: a spray of unique junk partitions (junk tokens resolve to the sender's fallback
/// partition, one per address) must not evict a partition that is currently throttled — the /// partition, one per address) must not evict a partition that is currently throttled — the