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
@@ -681,6 +681,51 @@ public class SecurityOptionsValidatorConfigGuardTests
Assert.True(result.Failed);
Assert.Contains(nameof(SecurityOptions.RoleRefreshThresholdMinutes), result.FailureMessage);
}
// ── arch-review 2026-08-19 P6-9: PerIpMaxFailures must exceed the per-pair ceiling ──
[Fact]
public void Validate_DefaultPerIpMaxFailures_Succeeds()
{
var result = new SecurityOptionsValidator().Validate(name: null, ValidOptions());
Assert.True(result.Succeeded);
}
[Fact]
public void Validate_PerIpMaxFailuresEqualsPerPairCeiling_Fails()
{
var options = ValidOptions();
options.PerIpMaxFailures = options.MaxLoginFailuresPerWindow; // 5 == 5
var result = new SecurityOptionsValidator().Validate(name: null, options);
Assert.True(result.Failed);
Assert.Contains(nameof(SecurityOptions.PerIpMaxFailures), result.FailureMessage);
Assert.Contains(nameof(SecurityOptions.MaxLoginFailuresPerWindow), result.FailureMessage);
}
[Fact]
public void Validate_PerIpMaxFailuresBelowPerPairCeiling_Fails()
{
var options = ValidOptions();
options.PerIpMaxFailures = 3; // < default MaxLoginFailuresPerWindow (5)
var result = new SecurityOptionsValidator().Validate(name: null, options);
Assert.True(result.Failed);
Assert.Contains(nameof(SecurityOptions.PerIpMaxFailures), result.FailureMessage);
}
[Fact]
public void Validate_PerIpMaxFailuresOneAbovePerPairCeiling_Succeeds()
{
var options = ValidOptions();
options.PerIpMaxFailures = options.MaxLoginFailuresPerWindow + 1; // tightest valid config
var result = new SecurityOptionsValidator().Validate(name: null, options);
Assert.True(result.Succeeded);
}
}
#endregion