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:
Joseph Doherty
2026-08-19 14:54:25 -04:00
parent 561582747e
commit 013257ec7c
5 changed files with 314 additions and 54 deletions
@@ -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;
}
}