ffba75bb93
#844: a success cleared the whole per-client count, so a client holding a valid key could make four guesses at another key id, sign in with its own, and repeat forever. Failures are now charged to the (client, credential identity) pair -- IOpcUaUserAuthenticator.DescribeCredential, key:<keyId> for API keys -- and to the client as a whole. A success clears only its own identity's count; the per-identity threshold locks that identity; a new per-client cap (OpcUa:CredentialLockout:MaxFailedAttemptsPerClient, default 2 x MaxFailedAttempts) that a success never resets bounds spraying across key ids and locks every credential. Slots are bounded on both counts; a waiter whose identity has room is not held behind one whose identity has none. #845: (1) store timeouts, miss-budget refusals, store/authenticator exceptions, an unset pepper and cancellation are reported Transient (OpcUaUserAuthResult.Unavailable) and released uncounted, so a ConfigDb stall can no longer lock every keyed client out; the client still sees the same opaque denial. (2) a full 4096-entry table defers its next prune until the earliest entry could age out instead of rescanning under the lock per failure. (3) ApplicationUri deliberately NOT added to the key: on a None channel it is a free string, so it would let an attacker reset the count per guess -- documented. (4) wire tests: distinct client certificates on a Sign endpoint are locked out separately; the ApplicationConfigPath branch keeps the SDK lockout off (falsified by removing the loaded-branch disable).
51 lines
3.4 KiB
C#
51 lines
3.4 KiB
C#
using ZB.MOM.WW.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
|
|
/// <summary>
|
|
/// Fail-fast startup validator for <see cref="OpcUaApplicationHostOptions"/>, built on the
|
|
/// shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. The C#
|
|
/// defaults are all valid, so a host with no explicit <c>"OpcUa"</c> section passes untouched;
|
|
/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots.
|
|
/// Identity/transport essentials (<c>ApplicationName</c>, <c>ApplicationUri</c>,
|
|
/// <c>PublicHostname</c>, <c>PkiStoreRoot</c>, <c>OpcUaPort</c>) must be present/valid and at
|
|
/// least one security profile must be enabled. Optional fields — <c>ApplicationConfigPath</c>,
|
|
/// <c>PeerApplicationUris</c>, <c>AutoAcceptUntrustedClientCertificates</c>, and
|
|
/// <c>ProductUri</c> — are intentionally not validated. Failure messages carry the real
|
|
/// <c>"OpcUa:"</c> section prefix matching the bound configuration section.
|
|
/// </summary>
|
|
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
|
|
{
|
|
/// <inheritdoc />
|
|
protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
|
|
{
|
|
builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
|
|
builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
|
|
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
|
|
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
|
|
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
|
|
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
|
|
// IReadOnlyCollection<T>, so it can't bind to MinCount's IReadOnlyCollection<T> parameter
|
|
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
|
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
|
|
|
// #833 review: an enabled credential lockout needs positive durations — a zero LockoutSeconds would lock
|
|
// nothing, a zero FailureWindowSeconds would never accumulate. MaxFailedAttempts <= 0 disables it.
|
|
var lockout = o.CredentialLockout;
|
|
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0
|
|
|| (lockout.LockoutSeconds > 0 && lockout.FailureWindowSeconds > 0 && lockout.SlotWaitSeconds > 0),
|
|
"OpcUa:CredentialLockout:LockoutSeconds, OpcUa:CredentialLockout:FailureWindowSeconds and "
|
|
+ "OpcUa:CredentialLockout:SlotWaitSeconds must all be "
|
|
+ "greater than 0 while OpcUa:CredentialLockout:MaxFailedAttempts is greater than 0 (set "
|
|
+ "MaxFailedAttempts to 0 to disable the lockout)");
|
|
|
|
// #844: the per-client cap (which a success never resets) below the per-credential threshold would make every
|
|
// per-credential lockout a client-wide one; 0 means 2 x MaxFailedAttempts.
|
|
builder.RequireThat(lockout is null || lockout.MaxFailedAttempts <= 0 || lockout.MaxFailedAttemptsPerClient <= 0
|
|
|| lockout.MaxFailedAttemptsPerClient >= lockout.MaxFailedAttempts,
|
|
"OpcUa:CredentialLockout:MaxFailedAttemptsPerClient must be 0 (= 2 x MaxFailedAttempts) or at least "
|
|
+ "OpcUa:CredentialLockout:MaxFailedAttempts");
|
|
}
|
|
}
|