fix(centralui): per-IP spray ceiling on LoginThrottle (arch-review 2026-08-19 P6-9)
LoginThrottle keyed failures only as {username}|{ip}, so a password spray
from one IP against many distinct usernames never accumulated
MaxLoginFailuresPerWindow failures on any single pair and was never
throttled, despite the class doc claiming spray protection.
Add a secondary per-IP counter (SecurityOptions.PerIpMaxFailures, default
25, validated strictly greater than MaxLoginFailuresPerWindow) that shares
the existing fixed window/lockout machinery but tracks failures by IP
alone. IsLockedOut now refuses a bind when either the per-pair or the
per-IP counter is locked. RecordSuccess clears only the per-pair counter
(never the per-IP one), so a spray that stumbles onto one valid account
cannot use that success to reset its IP budget and keep spraying; window
expiry is the only relief for the per-IP counter. Per-pair behavior for
normal (non-spray) flows is unchanged.
Updates the class doc and Component-Security.md to describe the two-tier
model instead of the single-tier spray claim.
This commit is contained in:
@@ -4,7 +4,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-window LDAP-bind failure lockout, keyed per <c>{username}|{ip}</c>.
|
||||
/// Fixed-window LDAP-bind failure lockout, layering a per-<c>{username}|{ip}</c> budget with a
|
||||
/// secondary, coarser per-IP budget.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -17,27 +18,51 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// determined spray hitting both nodes still burns its budget twice as fast on each).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two tiers, same clock.</b> The primary counter is keyed per <c>{username}|{ip}</c> pair
|
||||
/// and stops a brute-force attempt against one account. On its own that counter is blind to a
|
||||
/// <b>spray</b> — one attacker IP trying one password against many distinct usernames never
|
||||
/// accumulates <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> failures on any single
|
||||
/// pair. A secondary counter keyed on the IP alone closes that gap: it uses the same fixed
|
||||
/// window (<see cref="SecurityOptions.LoginFailureWindowMinutes"/>) and lockout duration
|
||||
/// (<see cref="SecurityOptions.LoginLockoutMinutes"/>) but a higher ceiling
|
||||
/// (<see cref="SecurityOptions.PerIpMaxFailures"/>), so a shared NAT gateway absorbing a
|
||||
/// handful of ordinary typos across a few users stays under it while a spray does not.
|
||||
/// <see cref="IsLockedOut"/> refuses a bind attempt when EITHER counter for the pair's IP is
|
||||
/// currently locked out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Success-path semantics are asymmetric by design.</b> <see cref="RecordSuccess"/> clears
|
||||
/// only the per-pair counter, never the per-IP counter — a spray that happens to guess (or
|
||||
/// already knows) one valid account on the sprayed IP must not be able to reset the IP's
|
||||
/// failure budget and keep spraying indefinitely. The per-IP counter's only relief is window
|
||||
/// expiry (or lockout expiry once armed); this is the cheapest safe choice and intentionally
|
||||
/// does not distinguish "this IP's failures are all from one confused legitimate user" from
|
||||
/// "this IP just found one valid account mid-spray" — the throttle cannot tell those apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The counter uses a <b>fixed window</b>: the first failure opens a window of
|
||||
/// <see cref="SecurityOptions.LoginFailureWindowMinutes"/>; reaching
|
||||
/// <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> failures inside that window locks
|
||||
/// the key out for <see cref="SecurityOptions.LoginLockoutMinutes"/>. A successful bind or an
|
||||
/// expired window resets the counter. Setting <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/>
|
||||
/// to 0 disables throttling entirely.
|
||||
/// <see cref="SecurityOptions.LoginFailureWindowMinutes"/>; reaching the tier's ceiling
|
||||
/// (<see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> per pair,
|
||||
/// <see cref="SecurityOptions.PerIpMaxFailures"/> per IP) inside that window locks the key out
|
||||
/// for <see cref="SecurityOptions.LoginLockoutMinutes"/>. An expired window resets the count.
|
||||
/// Setting <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> to 0 disables both tiers
|
||||
/// entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered as a singleton (<see cref="ServiceCollectionExtensions.AddSecurity"/>) so the
|
||||
/// counters are shared across all requests on a node. Thread-safe via
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> and atomic entry swaps. Memory is bounded:
|
||||
/// each write opportunistically prunes expired entries, and if the map still exceeds
|
||||
/// <see cref="MaxTrackedKeys"/> the oldest-windowed entries are dropped — so a spray across a
|
||||
/// large IP/username space cannot grow the map without limit.
|
||||
/// each write opportunistically prunes expired entries in both maps, and if either map still
|
||||
/// exceeds <see cref="MaxTrackedKeys"/> the oldest-windowed entries are dropped — so a spray
|
||||
/// across a large IP/username space cannot grow either map without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LoginThrottle
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard cap on the number of tracked <c>{username}|{ip}</c> keys. Above this the
|
||||
/// oldest-windowed entries are dropped on write to bound memory under a spray attack.
|
||||
/// Hard cap on the number of tracked keys in EITHER the per-pair (<c>{username}|{ip}</c>)
|
||||
/// or per-IP map. Above this the oldest-windowed entries are dropped on write to bound
|
||||
/// memory under a spray attack.
|
||||
/// </summary>
|
||||
public const int MaxTrackedKeys = 10_000;
|
||||
|
||||
@@ -48,16 +73,23 @@ public sealed class LoginThrottle
|
||||
/// window/lockout expiry directly and never depends on pruning.</summary>
|
||||
internal const int PruneEveryNFailures = 64;
|
||||
|
||||
/// <summary>Diagnostics/test accessor: number of currently tracked keys.</summary>
|
||||
/// <summary>Diagnostics/test accessor: number of currently tracked per-pair keys.</summary>
|
||||
internal int TrackedKeyCount => _entries.Count;
|
||||
|
||||
/// <summary>Diagnostics/test accessor: number of currently tracked per-IP keys.</summary>
|
||||
internal int TrackedIpCount => _ipEntries.Count;
|
||||
|
||||
private int _failureWrites;
|
||||
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly IOptions<SecurityOptions> _options;
|
||||
|
||||
/// <summary>Primary tier: one entry per <c>{username}|{ip}</c> pair.</summary>
|
||||
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Secondary tier: one entry per IP, catching a spray across many usernames.</summary>
|
||||
private readonly ConcurrentDictionary<string, Entry> _ipEntries = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// One tracked key's state: the failure <paramref name="Count"/>, the
|
||||
/// <paramref name="WindowStart"/> that anchors the fixed failure window, and — once the
|
||||
@@ -76,26 +108,36 @@ public sealed class LoginThrottle
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the given <paramref name="username"/> + <paramref name="ip"/> pair is
|
||||
/// currently locked out and a bind must be refused without contacting LDAP. Never mutates
|
||||
/// state (an expired lockout simply reads as unlocked; the entry is cleared on the next write).
|
||||
/// currently locked out, OR <paramref name="ip"/> itself is locked out under the secondary
|
||||
/// spray ceiling, and a bind must be refused without contacting LDAP. Never mutates state
|
||||
/// (an expired lockout simply reads as unlocked; the entry is cleared on the next write).
|
||||
/// </summary>
|
||||
/// <param name="username">The username being authenticated.</param>
|
||||
/// <param name="ip">The client IP address of the bind attempt.</param>
|
||||
/// <returns><c>true</c> when the key is currently locked out; otherwise <c>false</c>.</returns>
|
||||
/// <returns><c>true</c> when the pair or its IP is currently locked out; otherwise <c>false</c>.</returns>
|
||||
public bool IsLockedOut(string username, string ip)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
if (opts.MaxLoginFailuresPerWindow <= 0) return false; // throttling disabled
|
||||
|
||||
var now = _clock.GetUtcNow();
|
||||
|
||||
if (_ipEntries.TryGetValue(IpKey(ip), out var ipEntry)
|
||||
&& ipEntry.LockedUntil is { } ipUntil && ipUntil > now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_entries.TryGetValue(Key(username, ip), out var entry)) return false;
|
||||
|
||||
return entry.LockedUntil is { } until && until > _clock.GetUtcNow();
|
||||
return entry.LockedUntil is { } until && until > now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a failed bind for the given <paramref name="username"/> + <paramref name="ip"/>.
|
||||
/// Opens or advances the fixed window and, on reaching the configured threshold, arms the
|
||||
/// lockout. No-op when throttling is disabled.
|
||||
/// Opens or advances the fixed window for BOTH the per-pair and the per-IP counter and, on
|
||||
/// either reaching its configured threshold, arms that counter's lockout. No-op when
|
||||
/// throttling is disabled.
|
||||
/// </summary>
|
||||
/// <param name="username">The username that failed authentication.</param>
|
||||
/// <param name="ip">The client IP address of the failed bind attempt.</param>
|
||||
@@ -107,12 +149,53 @@ public sealed class LoginThrottle
|
||||
var now = _clock.GetUtcNow();
|
||||
var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
|
||||
var lockout = TimeSpan.FromMinutes(opts.LoginLockoutMinutes);
|
||||
var key = Key(username, ip);
|
||||
|
||||
_entries.AddOrUpdate(
|
||||
RecordFailureIn(_entries, Key(username, ip), opts.MaxLoginFailuresPerWindow, window, lockout, now);
|
||||
RecordFailureIn(_ipEntries, IpKey(ip), opts.PerIpMaxFailures, window, lockout, now);
|
||||
|
||||
// Amortized prune (R2 N8): every Nth failure, or immediately when either map is over
|
||||
// the hard cap — the cap is the memory-safety invariant and must never wait for the
|
||||
// cadence.
|
||||
if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0
|
||||
|| _entries.Count > MaxTrackedKeys || _ipEntries.Count > MaxTrackedKeys)
|
||||
{
|
||||
Prune(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful bind for the given <paramref name="username"/> +
|
||||
/// <paramref name="ip"/>, clearing any accumulated failure count and lockout for that PAIR
|
||||
/// only. Deliberately does <b>not</b> touch the IP's secondary counter — see the class
|
||||
/// remarks on success-path asymmetry: resetting the per-IP counter on any successful bind
|
||||
/// would let a spray that stumbles onto (or already knows) one valid account on the
|
||||
/// sprayed IP keep spraying indefinitely. The per-IP counter's only relief is window/lockout
|
||||
/// expiry.
|
||||
/// </summary>
|
||||
/// <param name="username">The username that authenticated successfully.</param>
|
||||
/// <param name="ip">The client IP address of the successful bind attempt.</param>
|
||||
public void RecordSuccess(string username, string ip)
|
||||
{
|
||||
_entries.TryRemove(Key(username, ip), out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one failure against <paramref name="key"/> in <paramref name="map"/>, opening or
|
||||
/// advancing its fixed window and arming its lockout once <paramref name="maxFailures"/> is
|
||||
/// reached. Shared by the per-pair and per-IP tiers — only the map, key, and ceiling differ.
|
||||
/// </summary>
|
||||
private static void RecordFailureIn(
|
||||
ConcurrentDictionary<string, Entry> map,
|
||||
string key,
|
||||
int maxFailures,
|
||||
TimeSpan window,
|
||||
TimeSpan lockout,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
map.AddOrUpdate(
|
||||
key,
|
||||
// First observed failure for this key: open a fresh window.
|
||||
_ => Arm(new Entry(1, now, null), opts, now, lockout),
|
||||
_ => Arm(new Entry(1, now, null), maxFailures, now, lockout),
|
||||
(_, existing) =>
|
||||
{
|
||||
// A live lockout stands until it expires — further failures don't extend it.
|
||||
@@ -124,68 +207,59 @@ public sealed class LoginThrottle
|
||||
// If the current window has lapsed (or a prior lockout has expired), restart it.
|
||||
if (existing.LockedUntil is not null || now - existing.WindowStart >= window)
|
||||
{
|
||||
return Arm(new Entry(1, now, null), opts, now, lockout);
|
||||
return Arm(new Entry(1, now, null), maxFailures, now, lockout);
|
||||
}
|
||||
|
||||
return Arm(existing with { Count = existing.Count + 1 }, opts, now, lockout);
|
||||
return Arm(existing with { Count = existing.Count + 1 }, maxFailures, now, lockout);
|
||||
});
|
||||
|
||||
// Amortized prune (R2 N8): every Nth failure, or immediately when over the hard
|
||||
// cap — the cap is the memory-safety invariant and must never wait for the cadence.
|
||||
if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0
|
||||
|| _entries.Count > MaxTrackedKeys)
|
||||
{
|
||||
Prune(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful bind for the given <paramref name="username"/> +
|
||||
/// <paramref name="ip"/>, clearing any accumulated failure count and lockout for that key.
|
||||
/// </summary>
|
||||
/// <param name="username">The username that authenticated successfully.</param>
|
||||
/// <param name="ip">The client IP address of the successful bind attempt.</param>
|
||||
public void RecordSuccess(string username, string ip)
|
||||
{
|
||||
_entries.TryRemove(Key(username, ip), out _);
|
||||
}
|
||||
|
||||
/// <summary>Arms the lockout on <paramref name="entry"/> if it has reached the threshold.</summary>
|
||||
private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) =>
|
||||
entry.Count >= opts.MaxLoginFailuresPerWindow
|
||||
/// <summary>Arms the lockout on <paramref name="entry"/> if it has reached <paramref name="maxFailures"/>.</summary>
|
||||
private static Entry Arm(Entry entry, int maxFailures, DateTimeOffset now, TimeSpan lockout) =>
|
||||
entry.Count >= maxFailures
|
||||
? entry with { LockedUntil = now + lockout }
|
||||
: entry;
|
||||
|
||||
/// <summary>
|
||||
/// Opportunistic memory management on write: drop entries whose window has fully expired
|
||||
/// (no active lockout), and if the map still exceeds <see cref="MaxTrackedKeys"/> evict the
|
||||
/// oldest-windowed entries so a spray across many keys cannot grow the map unbounded.
|
||||
/// (no active lockout) in BOTH maps, and if either map still exceeds
|
||||
/// <see cref="MaxTrackedKeys"/> evict its oldest-windowed entries so a spray across many
|
||||
/// keys cannot grow either map unbounded.
|
||||
/// </summary>
|
||||
private void Prune(DateTimeOffset now)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
|
||||
|
||||
foreach (var kvp in _entries)
|
||||
PruneMap(_entries, window, now);
|
||||
PruneMap(_ipEntries, window, now);
|
||||
}
|
||||
|
||||
private static void PruneMap(ConcurrentDictionary<string, Entry> map, TimeSpan window, DateTimeOffset now)
|
||||
{
|
||||
foreach (var kvp in map)
|
||||
{
|
||||
var e = kvp.Value;
|
||||
var lockActive = e.LockedUntil is { } until && until > now;
|
||||
if (!lockActive && now - e.WindowStart >= window)
|
||||
{
|
||||
_entries.TryRemove(kvp);
|
||||
map.TryRemove(kvp);
|
||||
}
|
||||
}
|
||||
|
||||
if (_entries.Count <= MaxTrackedKeys) return;
|
||||
if (map.Count <= MaxTrackedKeys) return;
|
||||
|
||||
// Still over the cap after pruning expired entries — evict the oldest-windowed keys.
|
||||
var excess = _entries.Count - MaxTrackedKeys;
|
||||
foreach (var kvp in _entries.OrderBy(k => k.Value.WindowStart).Take(excess))
|
||||
var excess = map.Count - MaxTrackedKeys;
|
||||
foreach (var kvp in map.OrderBy(k => k.Value.WindowStart).Take(excess))
|
||||
{
|
||||
_entries.TryRemove(kvp);
|
||||
map.TryRemove(kvp);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Key(string username, string ip) =>
|
||||
$"{(username ?? string.Empty).ToLowerInvariant()}|{ip ?? string.Empty}";
|
||||
|
||||
/// <summary>Key for the secondary, IP-only tier — deliberately no username component.</summary>
|
||||
private static string IpKey(string ip) => ip ?? string.Empty;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,18 @@ public class SecurityOptions
|
||||
/// <see cref="MaxLoginFailuresPerWindow"/> failures within the window. Default: <b>5</b>.
|
||||
/// </summary>
|
||||
public int LoginLockoutMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Secondary, IP-only failure ceiling layered on top of the per-<c>{username}|{ip}</c>
|
||||
/// budget (<see cref="MaxLoginFailuresPerWindow"/>) — closes the password-spray gap where
|
||||
/// one attacker IP tries one password against many distinct usernames, never accumulating
|
||||
/// <see cref="MaxLoginFailuresPerWindow"/> failures on any single pair. Reuses the same
|
||||
/// <see cref="LoginFailureWindowMinutes"/> window and <see cref="LoginLockoutMinutes"/>
|
||||
/// lockout duration, just counted per IP instead of per pair. Set higher than the per-pair
|
||||
/// ceiling (validated by <see cref="SecurityOptionsValidator"/>) so a handful of ordinary
|
||||
/// typos behind a shared NAT gateway don't trip it. Default: <b>25</b>.
|
||||
/// </summary>
|
||||
public int PerIpMaxFailures { get; set; } = 25;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -186,6 +198,20 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
|
||||
$"({options.LoginLockoutMinutes}) must be non-negative.");
|
||||
}
|
||||
|
||||
// SECURITY: the per-IP spray ceiling must sit strictly above the per-pair ceiling.
|
||||
// If it didn't, a single unlucky (or malicious) username/IP pair could trip the
|
||||
// IP-wide lockout before its own per-pair budget is exhausted, locking out every
|
||||
// OTHER username sharing that IP (e.g. a NAT gateway) on account of one pair's
|
||||
// failures — defeating the "don't punish shared IPs for a couple of typos" intent.
|
||||
if (options.PerIpMaxFailures <= options.MaxLoginFailuresPerWindow)
|
||||
{
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
||||
$"{nameof(SecurityOptions.PerIpMaxFailures)} " +
|
||||
$"({options.PerIpMaxFailures}) must be strictly greater than " +
|
||||
$"{nameof(SecurityOptions.MaxLoginFailuresPerWindow)} " +
|
||||
$"({options.MaxLoginFailuresPerWindow}).");
|
||||
}
|
||||
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user