fix(SEC-38,SEC-39): preserve aggregate and fallback throttles
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user