fix(opcua): credential lockout review fixes (#833)
- Reserve an in-flight slot per client before authenticating (TryBegin / Complete / Abandon under one lock); refuse once recorded failures + in-flight attempts reach MaxFailedAttempts, so a parallel burst cannot pass the check before its first failure is recorded. - An expired lockout resets the count (LockoutSeconds < FailureWindowSeconds relocked on every single failure after the first lockout). - Validate OpcUa:CredentialLockout at host start: disabled, or both durations > 0. - Log the first refusal of a lockout episode at Information, the rest at Debug. - Document that on None endpoints the key is effectively IP-only (NAT neighbours share a UserName count) and a null peer shares one key.
This commit is contained in:
+17
-4
@@ -334,10 +334,23 @@ The host now sets `MaxFailedAuthenticationAttempts = 0` on every configuration i
|
||||
- **UserName activations only.** A failed one counts; at `MaxFailedAttempts` (5) within
|
||||
`FailureWindowSeconds` (60) of each other, that client's **UserName** activations are refused
|
||||
`BadUserAccessDenied` for `LockoutSeconds` (300) without the key store being consulted, and a Warning is
|
||||
logged once. A refusal during the lockout is not counted (it never extends itself); a successful UserName
|
||||
activation clears the count. **Anonymous activations never consult it**, from any client.
|
||||
- **Keyed on certificate thumbprint *and* peer address together**, so locking a client out needs both its
|
||||
certificate and its source address, and NAT neighbours with different certificates do not share a count.
|
||||
logged once. A refusal during the lockout is not counted (it never extends itself); an **expired lockout
|
||||
always starts a fresh count**, whatever `FailureWindowSeconds` is; a successful UserName activation clears the
|
||||
count. **Anonymous activations never consult it**, from any client. The first refusal of each lockout episode
|
||||
is logged at Information, the rest at Debug.
|
||||
- **Parallel activations are held to the threshold.** The check reserves an in-flight slot under the same lock,
|
||||
and a client whose recorded failures plus in-progress UserName activations already reach
|
||||
`MaxFailedAttempts` is refused (`BadUserAccessDenied`, "too many authentication attempts in progress"), so a
|
||||
burst cannot slip past the check before its first failure is recorded. ⚠️ The same rule caps a **legitimate**
|
||||
client at `MaxFailedAttempts` concurrent UserName activations (the check is milliseconds, so this only bites a
|
||||
burst of simultaneous session opens from one key).
|
||||
- **Keyed on certificate thumbprint *and* peer address together.** That separates clients only when they present
|
||||
a certificate. ⚠️ On a `None` endpoint clients commonly present none, so the key is **effectively the IP alone**:
|
||||
NAT neighbours there **do** share one count (for UserName activations only — never for anonymous ones). When the
|
||||
transport reports no peer address at all, every certificate-less client shares the single key `-@-`. (Observed on
|
||||
docker-dev: every host client arrives as `-@192.168.97.1`.)
|
||||
- **Validated at host start:** with `MaxFailedAttempts > 0`, both `LockoutSeconds` and `FailureWindowSeconds` must be
|
||||
`> 0` (`OpcUaApplicationHostOptionsValidator`); `MaxFailedAttempts <= 0` disables the lockout.
|
||||
- Every denial counts, including a key-store timeout or miss-budget refusal (as the SDK's did) — a ConfigDb
|
||||
outage can therefore lock a keyed client out for up to `LockoutSeconds` after recovery.
|
||||
- Bounded at 4096 tracked clients (expired entries are pruned; a client arriving at a full table is not
|
||||
|
||||
Reference in New Issue
Block a user