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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user