Fail closed when reconnect cannot preserve endpoint security (#4809)

# Description

It was reported that endpoint refresh during reconnect
(`UpdateBeforeConnect` / reconnect endpoint update) could silently
change a connection's security configuration (e.g. from `SignAndEncrypt`
to a different mode/policy) without the application knowing.

Investigation showed the hard downgrade paths had already been fixed,
but the legacy `SessionReconnectHandler` still had a composite-score
fallback that could select a *different* security mode/policy when the
original combination was no longer offered by the (unauthenticated)
discovery reply. A stronger mode could offset a weaker or deprecated
policy in that score. Managed failover/alternate-network endpoints also
had no explicit mode/policy guard.

Changes:
- **`SessionReconnectHandler`**: removed `SelectFallbackEndpoint` and
the alternate-policy fallback. If the exact mode/policy is unavailable,
reconnect fails (logged as an error) instead of changing security.
- **`ManagedSession`**: added a mode/policy mismatch check; mismatched
alternate-network endpoints, reactivation targets and
failover/recreation targets are rejected with `BadSecurityModeRejected`
/ `BadSecurityPolicyRejected` before a channel is opened.
- Endpoint refresh for **certificate rotation is retained**; new server
certificates are still subject to normal trust validation.
- **Docs** (`docs/Sessions.md`): documents exact mode/policy
preservation during recovery and that `UpdateBeforeConnect` does not
authorize security negotiation. Changing policy requires explicit
reconfiguration and a new connection.
- **Tests**: replaced fallback-selection tests with a fail-closed legacy
reconnect test; added managed alternate-endpoint policy mismatch and
failover mode mismatch tests.

Validation:
- `dotnet test tests/Opc.Ua.Client.Tests/Opc.Ua.Client.Tests.csproj -f
net10.0 --filter
"FullyQualifiedName~SessionReconnectHandlerTests|FullyQualifiedName~ManagedSessionTests"`
— passed (48/48)
- `dotnet test tests/Opc.Ua.Core.Tests/Opc.Ua.Core.Tests.csproj -f
net10.0 --filter "FullyQualifiedName~ConfiguredEndpointTests"` — passed
(46/46)
- Full UA.slnx suite and net48 tests: not run locally.

## Related Issues

- Reported security concern: reconnect endpoint refresh could change the
session's security configuration without notifying the application.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marc Schier
2026-10-15 20:33:35 +02:00
committed by GitHub
parent 1b7db89f26
commit 1f6700c30b
5 changed files with 411 additions and 261 deletions
@@ -361,6 +361,161 @@ namespace Opc.Ua.Client.Tests.ManagedSession
Assert.That(capturedEndpoint, Is.SameAs(alternateEndpoint));
}
[Test]
public async Task HandleConnectAsyncSkipsIncompatibleAlternateBeforeNextCompatibleEndpointAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
ConfiguredEndpoint primaryEndpoint = CreateEndpoint();
primaryEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
primaryEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
ConfiguredEndpoint incompatibleEndpoint = CreateEndpoint();
incompatibleEndpoint.Description.EndpointUrl = "opc.tcp://incompatible:4840";
incompatibleEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
incompatibleEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss;
ConfiguredEndpoint compatibleEndpoint = CreateEndpoint();
compatibleEndpoint.Description.EndpointUrl = "opc.tcp://compatible:4840";
compatibleEndpoint.Description.SecurityMode = MessageSecurityMode.SignAndEncrypt;
compatibleEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
var attemptedEndpoints = new List<ConfiguredEndpoint>();
var sessionFactory = new Mock<ISessionFactory>();
sessionFactory.SetupGet(factory => factory.Telemetry).Returns(telemetry);
sessionFactory.Setup(factory => factory.WithSubscriptionEngine(
It.IsAny<ISubscriptionEngineFactory>(),
It.IsAny<TimeProvider?>()))
.Returns(sessionFactory.Object);
sessionFactory.Setup(factory => factory.CreateAsync(
It.IsAny<ApplicationConfiguration>(),
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<bool>(),
It.IsAny<bool>(),
It.IsAny<string>(),
It.IsAny<uint>(),
It.IsAny<IUserIdentity>(),
It.IsAny<ArrayOf<string>>(),
It.IsAny<CancellationToken>()))
.Returns((
ApplicationConfiguration _,
ConfiguredEndpoint endpoint,
bool _,
bool _,
string _,
uint _,
IUserIdentity _,
ArrayOf<string> _,
CancellationToken _) =>
{
attemptedEndpoints.Add(endpoint);
StatusCode statusCode = ReferenceEquals(endpoint, primaryEndpoint)
? StatusCodes.BadSecureChannelClosed
: StatusCodes.BadCertificateUntrusted;
return Task.FromException<ISession>(new ServiceResultException(statusCode));
});
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
configuration,
primaryEndpoint,
null,
telemetry,
new NetworkRedundancyOptions
{
AlternateEndpoints = new ArrayOf<ConfiguredEndpoint>(
new[] { incompatibleEndpoint, compatibleEndpoint }.AsMemory())
},
sessionFactory: sessionFactory.Object);
ServiceResult result = await InvokeHandleConnectAsync(managedSession)
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUntrusted));
Assert.That(attemptedEndpoints, Is.EqualTo(new[] { primaryEndpoint, compatibleEndpoint }));
}
[TestCase(false)]
[TestCase(true)]
public async Task HandleReconnectAsyncSkipsAlternateWithDifferentSecurityPolicyAsync(
bool hasCompatibleAlternate)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
ConfiguredEndpoint primaryEndpoint = CreateEndpoint();
ConfiguredEndpoint alternateEndpoint = CreateEndpoint();
alternateEndpoint.Description.EndpointUrl = "opc.tcp://alternate:4840";
alternateEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
ConfiguredEndpoint compatibleEndpoint = CreateEndpoint();
compatibleEndpoint.Description.EndpointUrl = "opc.tcp://compatible:4840";
IServiceMessageContext messageContext = configuration.CreateMessageContext();
var managedChannel = new Mock<IManagedTransportChannel>();
managedChannel.SetupGet(channel => channel.MessageContext).Returns(messageContext);
managedChannel.SetupGet(channel => channel.State).Returns(ChannelState.Closed);
ConfiguredEndpoint? capturedEndpoint = null;
var channelManager = new Mock<IClientChannelManager>();
#if NET8_0_OR_GREATER
channelManager.Setup(manager => manager.ReconnectAsync(
managedChannel.Object,
It.IsAny<IRetryBudget>(),
It.IsAny<CancellationToken>()))
#else
channelManager.Setup(manager => manager.ReconnectAsync(
managedChannel.Object, It.IsAny<CancellationToken>()))
#endif
.Throws(new ServiceResultException(StatusCodes.BadSecureChannelClosed));
channelManager.Setup(manager => manager.GetAsync(
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<Func<IManagedTransportChannel, IReconnectParticipant>>(),
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<CancellationToken>()))
.Callback<ConfiguredEndpoint, Func<IManagedTransportChannel, IReconnectParticipant>,
ITransportWaitingConnection?, CancellationToken>(
(endpoint, _, _, _) => capturedEndpoint = endpoint)
.Throws(new ServiceResultException(StatusCodes.BadUnexpectedError));
using var innerSession = new Session(
managedChannel.Object,
configuration,
primaryEndpoint,
engineFactory: DefaultSubscriptionEngineFactory.Instance);
innerSession.BindManagedChannel(channelManager.Object, managedChannel.Object);
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
configuration,
primaryEndpoint,
innerSession,
telemetry,
new NetworkRedundancyOptions
{
AlternateEndpoints = hasCompatibleAlternate
? new ArrayOf<ConfiguredEndpoint>(
new[] { alternateEndpoint, compatibleEndpoint }.AsMemory())
: new ArrayOf<ConfiguredEndpoint>(new[] { alternateEndpoint }.AsMemory())
});
ServiceResult result = await InvokeHandleReconnectAsync(
managedSession,
new RetryBudget(TimeSpan.FromSeconds(30)))
.ConfigureAwait(false);
Assert.That(
result.StatusCode,
Is.EqualTo(hasCompatibleAlternate
? StatusCodes.BadUnexpectedError
: StatusCodes.BadSecurityPolicyRejected));
if (hasCompatibleAlternate)
{
Assert.That(capturedEndpoint, Is.SameAs(compatibleEndpoint));
}
else
{
Assert.That(capturedEndpoint, Is.Null);
}
Assert.That(
primaryEndpoint.Description.SecurityPolicyUri,
Is.EqualTo(SecurityPolicies.None));
}
[Test]
public async Task HandleFailoverAsyncUsesInnerSessionCurrentEndpointForTargetSelectionAsync()
{
@@ -370,6 +525,9 @@ namespace Opc.Ua.Client.Tests.ManagedSession
initialEndpoint.Description.EndpointUrl = "opc.tcp://initial:4840";
ConfiguredEndpoint activeEndpoint = CreateEndpoint();
activeEndpoint.Description.EndpointUrl = "opc.tcp://active:4840";
ConfiguredEndpoint failoverEndpoint = CreateEndpoint();
failoverEndpoint.Description.SecurityMode = MessageSecurityMode.Sign;
failoverEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
IServiceMessageContext messageContext = configuration.CreateMessageContext();
var managedChannel = new Mock<IManagedTransportChannel>();
@@ -382,7 +540,7 @@ namespace Opc.Ua.Client.Tests.ManagedSession
It.IsAny<ConfiguredEndpoint>()))
.Callback<ServerRedundancyInfo, ConfiguredEndpoint>(
(_, currentEndpoint) => capturedCurrentEndpoint = currentEndpoint)
.Returns((ConfiguredEndpoint?)null);
.Returns(failoverEndpoint);
using var innerSession = new Session(
managedChannel.Object,
@@ -408,8 +566,9 @@ namespace Opc.Ua.Client.Tests.ManagedSession
new RetryBudget(TimeSpan.FromSeconds(30)))
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeRejected));
Assert.That(capturedCurrentEndpoint, Is.SameAs(activeEndpoint));
Assert.That(activeEndpoint.Description.SecurityMode, Is.EqualTo(MessageSecurityMode.None));
redundancyHandler.Verify(h => h.SelectFailoverTarget(
It.IsAny<ServerRedundancyInfo>(),
It.IsAny<ConfiguredEndpoint>()),
@@ -1498,6 +1657,28 @@ namespace Opc.Ua.Client.Tests.ManagedSession
#pragma warning restore CA1873
}
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for reconnect-path tests that need the private handler.
private static Task<ServiceResult> InvokeHandleConnectAsync(
Client.ManagedSession managedSession)
{
MethodInfo? method = typeof(Client.ManagedSession).GetMethod(
"HandleConnectAsync",
BindingFlags.NonPublic | BindingFlags.Instance,
null,
[typeof(CancellationToken)],
null);
Assert.That(method, Is.Not.Null);
var task = (Task<ServiceResult>?)method!.Invoke(
managedSession,
[CancellationToken.None]);
Assert.That(task, Is.Not.Null);
return task!;
}
#pragma warning restore IDE0051, RCS1213
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for reconnect-path tests that need the private handler.
private static Task<ServiceResult> InvokeHandleReconnectAsync(
Client.ManagedSession managedSession,
@@ -1548,15 +1729,16 @@ namespace Opc.Ua.Client.Tests.ManagedSession
private static Client.ManagedSession CreateManagedSessionWithInner(
ApplicationConfiguration configuration,
ConfiguredEndpoint endpoint,
Session innerSession,
Session? innerSession,
ITelemetryContext telemetry,
NetworkRedundancyOptions? networkRedundancy = null,
IServerRedundancyHandler? redundancyHandler = null,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
ISessionFactory? sessionFactory = null)
{
ILogger<Client.ManagedSession> logger = telemetry.CreateLogger<Client.ManagedSession>();
var sessionFactory = new Mock<ISessionFactory>();
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
var defaultSessionFactory = new Mock<ISessionFactory>();
defaultSessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
var reconnectPolicy = new ReconnectPolicy(new ReconnectPolicyOptions
{
MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
@@ -1595,7 +1777,7 @@ namespace Opc.Ua.Client.Tests.ManagedSession
[
configuration,
endpoint,
sessionFactory.Object,
sessionFactory ?? defaultSessionFactory.Object,
reconnectPolicy,
redundancyHandler,
logger,
@@ -38,7 +38,7 @@ using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// Unit tests for SessionReconnectHandler, focusing on endpoint fallback behavior
/// Unit tests for SessionReconnectHandler, focusing on endpoint refresh behavior
/// when the server's endpoint configuration changes.
/// </summary>
[TestFixture]
@@ -274,32 +274,28 @@ namespace Opc.Ua.Client.Tests
}
/// <summary>
/// Verifies that when the endpoint update succeeds after a simulated
/// BadSecurityPolicyRejected fallback (modeled via TestableSessionReconnectHandler),
/// the session is recreated and the reconnect succeeds.
/// This simulates a server restarting with a different endpoint configuration where
/// the fallback selects the best available endpoint.
/// Verifies that a failed exact-security endpoint refresh prevents
/// recreation under a different security configuration.
/// </summary>
[Test]
public async Task DoReconnectAsync_WhenEndpointUpdateSucceedsAfterFallback_SessionIsRecreated()
public async Task DoReconnectAsync_WhenEndpointSecurityConfigurationIsUnavailable_DoesNotRecreateSession()
{
// Arrange
var mockNewSession = new Mock<ISession>();
var mockFactory = new Mock<ISessionFactory>();
ConfiguredEndpoint configuredEndpoint = CreateConfiguredEndpoint(
MessageSecurityMode.SignAndEncrypt,
SecurityPolicies.Aes256_Sha256_RsaPss);
SecurityPolicies.Basic256Sha256);
Mock<ISession> mockSession = CreateMockSession(configuredEndpoint, mockFactory);
mockFactory.Setup(f => f.RecreateAsync(mockSession.Object, It.IsAny<CancellationToken>()))
.ReturnsAsync(mockNewSession.Object);
// Simulate: the handler successfully updates the endpoint
// (the base class would have fallen back to best available endpoint here)
using var handler = new TestableSessionReconnectHandler(
var discoveredEndpoint = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss
};
using var handler = new ControlledDiscoverySessionReconnectHandler(
m_telemetry,
(endpoint, connection) => Task.CompletedTask);
discoveredEndpoint);
SetSessionHandlerField(handler, "m_reconnectFailed", true);
SetSessionHandlerField(handler, "m_updateFromServer", true);
@@ -309,126 +305,17 @@ namespace Opc.Ua.Client.Tests
bool result = await InvokeDoReconnectAsync(handler).ConfigureAwait(false);
// Assert
Assert.That(result, Is.True);
Assert.That(handler.Session, Is.SameAs(mockNewSession.Object));
}
/// <summary>
/// The fallback must never downgrade a secured endpoint, even if the
/// (unauthenticated) discovery reply advertises a weaker endpoint with a
/// high SecurityLevel.
/// </summary>
[Test]
public void SelectFallbackEndpoint_OnlyWeakerEndpoints_ReturnsNull()
{
var original = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
};
ArrayOf<EndpointDescription> discovered =
[
new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
SecurityLevel = 255
},
new EndpointDescription
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss,
SecurityLevel = 255
},
new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic128Rsa15,
SecurityLevel = 255
}
];
EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint(
original,
discovered,
m_telemetry.CreateLogger<SessionReconnectHandlerTests>())!;
Assert.That(fallback, Is.Null);
}
/// <summary>
/// The fallback picks the strongest endpoint by the locally computed
/// security level, ignoring the server advertised SecurityLevel.
/// </summary>
[Test]
public void SelectFallbackEndpoint_PicksStrongestByLocalSecurityLevel()
{
var original = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
};
var weakerAdvertisedHigh = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
SecurityLevel = 255
};
var aes128 = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Aes128_Sha256_RsaOaep,
SecurityLevel = 200
};
var aes256 = new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Aes256_Sha256_RsaPss,
SecurityLevel = 1
};
EndpointDescription fallback = SessionReconnectHandler.SelectFallbackEndpoint(
original,
[weakerAdvertisedHigh, aes128, aes256],
m_telemetry.CreateLogger<SessionReconnectHandlerTests>())!;
Assert.That(fallback, Is.SameAs(aes256));
}
/// <summary>
/// Even an unsecured original endpoint only falls back to a secured one.
/// </summary>
[Test]
public void SelectFallbackEndpoint_NoneOriginal_NeverSelectsNone()
{
var original = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
};
var none = new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
};
var signed = new EndpointDescription
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
};
Assert.That(result, Is.False);
Assert.That(handler.Session, Is.SameAs(mockSession.Object));
Assert.That(configuredEndpoint.Description.SecurityMode, Is.EqualTo(MessageSecurityMode.SignAndEncrypt));
Assert.That(
SessionReconnectHandler.SelectFallbackEndpoint(
original,
[none],
m_telemetry.CreateLogger<SessionReconnectHandlerTests>()),
Is.Null);
Assert.That(
SessionReconnectHandler.SelectFallbackEndpoint(
original,
[none, signed],
m_telemetry.CreateLogger<SessionReconnectHandlerTests>()),
Is.SameAs(signed));
configuredEndpoint.Description.SecurityPolicyUri,
Is.EqualTo(SecurityPolicies.Basic256Sha256));
mockFactory.Verify(
factory => factory.RecreateAsync(
mockSession.Object,
It.IsAny<CancellationToken>()),
Times.Never);
}
private static ConfiguredEndpoint CreateConfiguredEndpoint(
@@ -516,5 +403,38 @@ namespace Opc.Ua.Client.Tests
return _updateDelegate(endpoint, connection!);
}
}
private sealed class ControlledDiscoverySessionReconnectHandler : SessionReconnectHandler
{
private readonly EndpointDescription m_discoveredEndpoint;
public ControlledDiscoverySessionReconnectHandler(
ITelemetryContext telemetry,
EndpointDescription discoveredEndpoint)
: base(telemetry)
{
m_discoveredEndpoint = discoveredEndpoint;
}
internal override Task UpdateFromServerAsync(
ConfiguredEndpoint endpoint,
ITransportWaitingConnection? connection,
MessageSecurityMode securityMode,
string securityPolicyUri)
{
typeof(ConfiguredEndpoint)
.GetProperty(nameof(ConfiguredEndpoint.DiscoveryEndpoints))!
.SetValue(endpoint, new ArrayOf<EndpointDescription>(
new[] { m_discoveredEndpoint }.AsMemory()));
if (securityPolicyUri != m_discoveredEndpoint.SecurityPolicyUri)
{
throw new ServiceResultException(StatusCodes.BadSecurityPolicyRejected);
}
endpoint.Description.SecurityPolicyUri = securityPolicyUri;
return Task.CompletedTask;
}
}
}
}