diff --git a/docker-env2/central-node-a/appsettings.Central.json b/docker-env2/central-node-a/appsettings.Central.json index 63697f15..e59703ab 100644 --- a/docker-env2/central-node-a/appsettings.Central.json +++ b/docker-env2/central-node-a/appsettings.Central.json @@ -35,7 +35,12 @@ "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, "RequireHttpsCookie": false, - "CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2" + "CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2", + "ForwardedHeaders": { + "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.", + "Enabled": true, + "KnownNetworks": [ "172.16.0.0/12" ] + } }, "Communication": { "DeploymentTimeout": "00:02:00", diff --git a/docker-env2/central-node-b/appsettings.Central.json b/docker-env2/central-node-b/appsettings.Central.json index 556955b5..11ddfdba 100644 --- a/docker-env2/central-node-b/appsettings.Central.json +++ b/docker-env2/central-node-b/appsettings.Central.json @@ -35,7 +35,12 @@ "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, "RequireHttpsCookie": false, - "CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2" + "CookieName": "ZB.MOM.WW.ScadaBridge.Auth.env2", + "ForwardedHeaders": { + "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.", + "Enabled": true, + "KnownNetworks": [ "172.16.0.0/12" ] + } }, "Communication": { "DeploymentTimeout": "00:02:00", diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json index bd39ffe2..66799ef1 100644 --- a/docker/central-node-a/appsettings.Central.json +++ b/docker/central-node-a/appsettings.Central.json @@ -38,7 +38,12 @@ "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, - "RequireHttpsCookie": false + "RequireHttpsCookie": false, + "ForwardedHeaders": { + "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.", + "Enabled": true, + "KnownNetworks": [ "172.16.0.0/12" ] + } }, "Communication": { "DeploymentTimeout": "00:02:00", diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json index bf0d092e..95c4aa9d 100644 --- a/docker/central-node-b/appsettings.Central.json +++ b/docker/central-node-b/appsettings.Central.json @@ -38,7 +38,12 @@ "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, - "RequireHttpsCookie": false + "RequireHttpsCookie": false, + "ForwardedHeaders": { + "_comment": "Traefik fronts this node on the external scadabridge-net docker network. Trust X-Forwarded-For from the docker bridge address pool so LoginThrottle keys on the real client IP (arch-review R2 N2). Narrow to the Traefik container IP if the network is pinned.", + "Enabled": true, + "KnownNetworks": [ "172.16.0.0/12" ] + } }, "Communication": { "DeploymentTimeout": "00:02:00", diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index 55b97d27..56ad975f 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -206,6 +206,7 @@ Role checks are expressed as named ASP.NET Core authorization policies (in `Auth To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards. - **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled. -- **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; remote IP from the connection). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely. +- **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; the IP half is the **forwarded-header-resolved client IP** when `ScadaBridge:Security:ForwardedHeaders` names a trusted proxy, otherwise the raw connection peer). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely. +- **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window. - **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message. - **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself. diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs index c25c6679..47f26d09 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs @@ -34,7 +34,7 @@ public class LoginThrottleTests 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("alice", "10.0.0.2")); // per username+IP key — real isolation in production requires the trusted-proxy ForwardedHeaders config (R2 N2); without it all clients share the proxy IP Assert.False(throttle.IsLockedOut("bob", "10.0.0.1")); clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed