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:
@@ -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