diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs b/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs new file mode 100644 index 00000000..e890b557 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs @@ -0,0 +1,166 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.Security; + +/// +/// Fixed-window LDAP-bind failure lockout, keyed per {username}|{ip}. +/// +/// +/// +/// Every LDAP-bind surface (the Central UI /auth/login and /auth/token +/// endpoints, and the ManagementAuthenticator-fronted POST /management, audit REST, +/// and debug hub) records bind outcomes here and consults before +/// attempting a bind. This blunts online password-spray / brute-force against the directory +/// without any external state — it is an in-memory, best-effort throttle per central node +/// (the two central nodes maintain independent counters; that is acceptable, since a +/// determined spray hitting both nodes still burns its budget twice as fast on each). +/// +/// +/// The counter uses a fixed window: the first failure opens a window of +/// ; reaching +/// failures inside that window locks +/// the key out for . A successful bind or an +/// expired window resets the counter. Setting +/// to 0 disables throttling entirely. +/// +/// +/// Registered as a singleton () so the +/// counters are shared across all requests on a node. Thread-safe via +/// and atomic entry swaps. Memory is bounded: +/// each write opportunistically prunes expired entries, and if the map still exceeds +/// the oldest-windowed entries are dropped — so a spray across a +/// large IP/username space cannot grow the map without limit. +/// +/// +public sealed class LoginThrottle +{ + /// + /// Hard cap on the number of tracked {username}|{ip} keys. Above this the + /// oldest-windowed entries are dropped on write to bound memory under a spray attack. + /// + public const int MaxTrackedKeys = 10_000; + + private readonly TimeProvider _clock; + private readonly IOptions _options; + + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + + /// + /// One tracked key's state: the failure , the + /// that anchors the fixed failure window, and — once the + /// key is locked — the instant the lockout expires. + /// + private readonly record struct Entry(int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil); + + /// Creates the throttle over the injected clock and security options. + /// Time source (wall clock in production; a fake in tests). + /// Security options carrying the throttle thresholds. + public LoginThrottle(TimeProvider clock, IOptions options) + { + _clock = clock; + _options = options; + } + + /// + /// Returns true when the given + 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). + /// + public bool IsLockedOut(string username, string ip) + { + var opts = _options.Value; + if (opts.MaxLoginFailuresPerWindow <= 0) return false; // throttling disabled + + if (!_entries.TryGetValue(Key(username, ip), out var entry)) return false; + + return entry.LockedUntil is { } until && until > _clock.GetUtcNow(); + } + + /// + /// Records a failed bind for the given + . + /// Opens or advances the fixed window and, on reaching the configured threshold, arms the + /// lockout. No-op when throttling is disabled. + /// + public void RecordFailure(string username, string ip) + { + var opts = _options.Value; + if (opts.MaxLoginFailuresPerWindow <= 0) return; // throttling disabled + + var now = _clock.GetUtcNow(); + var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes); + var lockout = TimeSpan.FromMinutes(opts.LoginLockoutMinutes); + var key = Key(username, ip); + + _entries.AddOrUpdate( + key, + // First observed failure for this key: open a fresh window. + _ => Arm(new Entry(1, now, null), opts, now, lockout), + (_, existing) => + { + // A live lockout stands until it expires — further failures don't extend it. + if (existing.LockedUntil is { } until && until > now) + { + return existing; + } + + // 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(existing with { Count = existing.Count + 1 }, opts, now, lockout); + }); + + Prune(now); + } + + /// + /// Records a successful bind for the given + + /// , clearing any accumulated failure count and lockout for that key. + /// + public void RecordSuccess(string username, string ip) + { + _entries.TryRemove(Key(username, ip), out _); + } + + /// Arms the lockout on if it has reached the threshold. + private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) => + entry.Count >= opts.MaxLoginFailuresPerWindow + ? entry with { LockedUntil = now + lockout } + : entry; + + /// + /// Opportunistic memory management on write: drop entries whose window has fully expired + /// (no active lockout), and if the map still exceeds evict the + /// oldest-windowed entries so a spray across many keys cannot grow the map unbounded. + /// + private void Prune(DateTimeOffset now) + { + var opts = _options.Value; + var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes); + + foreach (var kvp in _entries) + { + var e = kvp.Value; + var lockActive = e.LockedUntil is { } until && until > now; + if (!lockActive && now - e.WindowStart >= window) + { + _entries.TryRemove(kvp); + } + } + + if (_entries.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)) + { + _entries.TryRemove(kvp); + } + } + + private static string Key(string username, string ip) => + $"{(username ?? string.Empty).ToLowerInvariant()}|{ip ?? string.Empty}"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs index 2a119baa..ac574715 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs @@ -104,6 +104,26 @@ public class SecurityOptions /// . Changing this invalidates existing sessions on next deploy. /// public string CookieName { get; set; } = DefaultCookieName; + + /// + /// Maximum failed LDAP binds tolerated per fixed window for a + /// single {username}|{ip} pair before the pair is locked out. Default: 5. + /// Set to 0 to disable login throttling entirely. + /// + public int MaxLoginFailuresPerWindow { get; set; } = 5; + + /// + /// The fixed-window length in minutes. Failures older than this + /// (measured from the window's first failure) no longer count toward the lockout threshold. + /// Default: 5. + /// + public int LoginFailureWindowMinutes { get; set; } = 5; + + /// + /// How long, in minutes, a {username}|{ip} pair stays locked out once it reaches + /// failures within the window. Default: 5. + /// + public int LoginLockoutMinutes { get; set; } = 5; } /// @@ -137,6 +157,32 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal $"enforcement is defeated."); } + // SECURITY: login-throttle thresholds must be non-negative. 0 for + // MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch; + // a negative value (or a negative window/lockout) is a misconfiguration that would + // make the fixed-window math ill-defined, so it fails fast at boot. + if (options.MaxLoginFailuresPerWindow < 0) + { + return Microsoft.Extensions.Options.ValidateOptionsResult.Fail( + $"{nameof(SecurityOptions.MaxLoginFailuresPerWindow)} " + + $"({options.MaxLoginFailuresPerWindow}) must be non-negative " + + $"(0 disables login throttling)."); + } + + if (options.LoginFailureWindowMinutes < 0) + { + return Microsoft.Extensions.Options.ValidateOptionsResult.Fail( + $"{nameof(SecurityOptions.LoginFailureWindowMinutes)} " + + $"({options.LoginFailureWindowMinutes}) must be non-negative."); + } + + if (options.LoginLockoutMinutes < 0) + { + return Microsoft.Extensions.Options.ValidateOptionsResult.Fail( + $"{nameof(SecurityOptions.LoginLockoutMinutes)} " + + $"({options.LoginLockoutMinutes}) must be non-negative."); + } + return Microsoft.Extensions.Options.ValidateOptionsResult.Success; } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index 40178054..c2ed6e86 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -99,6 +99,12 @@ public static class ServiceCollectionExtensions services.AddOptions().ValidateOnStart(); services.AddSingleton, SecurityOptionsValidator>(); + // Fixed-window LDAP-bind failure lockout (arch-review P1). A singleton so its + // per-{username|ip} failure counters are shared across every request on this node; + // consulted before, and updated after, every LDAP-bind surface (login/token endpoints + // and the ManagementAuthenticator-fronted management/audit/debug paths). + services.AddSingleton(); + // Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer + // LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose // LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs new file mode 100644 index 00000000..c25c6679 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +/// +/// Exercises : the fixed-window per-(username+IP) +/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable +/// (the central package list has no +/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the +/// CookieSessionValidator + Transport tests, which hand-roll the same clock). +/// +public class LoginThrottleTests +{ + private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero); + + private sealed class TestTimeProvider : TimeProvider + { + private DateTimeOffset _now; + public TestTimeProvider(DateTimeOffset start) => _now = start; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan by) => _now = _now.Add(by); + } + + private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) => + new(clock, Options.Create(options ?? new SecurityOptions())); + + [Fact] + public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock); + + for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1"); + + Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); + Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key + Assert.False(throttle.IsLockedOut("bob", "10.0.0.1")); + + clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + } + + [Fact] + public void SuccessResets() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock); + + for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1"); + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + + throttle.RecordSuccess("alice", "10.0.0.1"); + + // A single failure after the reset must NOT lock out — the counter restarted. + throttle.RecordFailure("alice", "10.0.0.1"); + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + } + + [Fact] + public void KeyIsCaseInsensitiveOnUsername() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock); + + for (var i = 0; i < 5; i++) throttle.RecordFailure("Alice", "10.0.0.1"); + + Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); + Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1")); + } + + [Fact] + public void FailuresOutsideWindow_DoNotAccumulate() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock); + + // Four failures, then let the failure window lapse before the fifth. + for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1"); + clock.Advance(TimeSpan.FromMinutes(6)); // > LoginFailureWindowMinutes (5) + throttle.RecordFailure("alice", "10.0.0.1"); + + // The window reset, so only one failure is counted — no lockout. + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + } + + [Fact] + public void LockoutPersistsUntilLockoutMinutesElapse() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock); + + for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1"); + Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); + + clock.Advance(TimeSpan.FromMinutes(4)); // still inside the 5-minute lockout + Assert.True(throttle.IsLockedOut("alice", "10.0.0.1")); + + clock.Advance(TimeSpan.FromMinutes(2)); // now past the 5-minute lockout + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + } + + [Fact] + public void ZeroMaxFailures_DisablesThrottling() + { + var clock = new TestTimeProvider(Start); + var throttle = Create(clock, new SecurityOptions { MaxLoginFailuresPerWindow = 0 }); + + for (var i = 0; i < 100; i++) throttle.RecordFailure("alice", "10.0.0.1"); + + Assert.False(throttle.IsLockedOut("alice", "10.0.0.1")); + } +}