fix(security): ship trusted-proxy ForwardedHeaders config for Traefik topologies + document lockout-DoS trade-off (plan R2-07 T3)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user