40969e5c4b
OPC Foundation SDK 2.0.1's SessionManager lockout refused EVERY activation (Anonymous included, before the token is read) from a client with 5 failed identity validations in 60 s, for 5 minutes, keyed on cert thumbprint else peer IP. Disable it (MaxFailedAuthenticationAttempts=0 on built and loaded configs) and enforce FailedCredentialLockout in the identity-registry adapter instead: UserName activations only, keyed on thumbprint+peer together, same 5/60s/300s defaults, configurable via OpcUa:CredentialLockout.
121 lines
5.2 KiB
C#
121 lines
5.2 KiB
C#
using System.Net;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>lmxopcua#833 — the UserName-only lockout that replaces the SDK's all-token lockout.</summary>
|
|
public sealed class FailedCredentialLockoutTests
|
|
{
|
|
private static readonly string Client = FailedCredentialLockout.KeyFor("THUMB", IPAddress.Parse("10.0.0.5"));
|
|
|
|
[Fact]
|
|
public void Locks_at_the_threshold_and_releases_after_the_lockout()
|
|
{
|
|
var clock = new ManualClock();
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
|
|
|
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
lockout.RecordFailure(Client).ShouldBeTrue("the fifth failure locks");
|
|
lockout.IsLockedOut(Client, out var remaining).ShouldBeTrue();
|
|
remaining.ShouldBe(TimeSpan.FromMinutes(5));
|
|
|
|
clock.Advance(TimeSpan.FromSeconds(299));
|
|
lockout.IsLockedOut(Client, out _).ShouldBeTrue();
|
|
clock.Advance(TimeSpan.FromSeconds(1));
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
lockout.RecordFailure(Client).ShouldBeFalse("a lockout that ran out starts a fresh count");
|
|
}
|
|
|
|
[Fact]
|
|
public void A_refusal_during_the_lockout_does_not_extend_it()
|
|
{
|
|
var clock = new ManualClock();
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, clock);
|
|
lockout.RecordFailure(Client).ShouldBeTrue();
|
|
clock.Advance(TimeSpan.FromSeconds(200));
|
|
lockout.RecordFailure(Client).ShouldBeFalse();
|
|
clock.Advance(TimeSpan.FromSeconds(100));
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Failures_spaced_wider_than_the_window_never_accumulate()
|
|
{
|
|
var clock = new ManualClock();
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
lockout.RecordFailure(Client).ShouldBeFalse();
|
|
clock.Advance(TimeSpan.FromSeconds(61));
|
|
}
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_success_clears_the_count()
|
|
{
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), new ManualClock());
|
|
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client);
|
|
lockout.RecordSuccess(Client);
|
|
for (var i = 0; i < 4; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void The_key_needs_both_certificate_and_address_so_one_client_cannot_lock_out_another()
|
|
{
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 1 }, new ManualClock());
|
|
var attacker = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.99"));
|
|
var victim = FailedCredentialLockout.KeyFor("VICTIMTHUMB", IPAddress.Parse("10.0.0.5"));
|
|
var natNeighbour = FailedCredentialLockout.KeyFor("OTHERTHUMB", IPAddress.Parse("10.0.0.99"));
|
|
|
|
lockout.RecordFailure(attacker).ShouldBeTrue();
|
|
lockout.IsLockedOut(victim, out _).ShouldBeFalse("same certificate, different address");
|
|
lockout.IsLockedOut(natNeighbour, out _).ShouldBeFalse("same address, different certificate");
|
|
}
|
|
|
|
[Fact]
|
|
public void Zero_attempts_disables_it()
|
|
{
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions { MaxFailedAttempts = 0 }, new ManualClock());
|
|
for (var i = 0; i < 50; i++) lockout.RecordFailure(Client).ShouldBeFalse();
|
|
lockout.IsLockedOut(Client, out _).ShouldBeFalse();
|
|
lockout.TrackedCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tracking_is_bounded_and_prunes_expired_clients_first()
|
|
{
|
|
var clock = new ManualClock();
|
|
var lockout = new FailedCredentialLockout(new OpcUaCredentialLockoutOptions(), clock);
|
|
for (var i = 0; i < FailedCredentialLockout.MaxTrackedClients; i++) lockout.RecordFailure($"c{i}");
|
|
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients);
|
|
|
|
lockout.RecordFailure("overflow");
|
|
lockout.TrackedCount.ShouldBe(FailedCredentialLockout.MaxTrackedClients, "full and nothing expired ⇒ not tracked");
|
|
|
|
clock.Advance(TimeSpan.FromSeconds(61));
|
|
lockout.RecordFailure("overflow");
|
|
lockout.TrackedCount.ShouldBe(1, "expired entries are pruned to make room");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_host_switches_the_sdk_lockout_off()
|
|
{
|
|
var config = new Opc.Ua.ServerConfiguration();
|
|
config.MaxFailedAuthenticationAttempts.ShouldBe(5, "the SDK default this guards against");
|
|
OpcUaApplicationHost.DisableSdkAuthenticationLockout(config);
|
|
config.MaxFailedAuthenticationAttempts.ShouldBe(0);
|
|
}
|
|
|
|
private sealed class ManualClock : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(2026, 11, 11, 0, 0, 0, TimeSpan.Zero);
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan by) => _now += by;
|
|
}
|
|
}
|