Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs
T
Joseph Doherty 9aff90f884 fix(opcuaclient): never pass checkDomain for a None endpoint; document both SDK check sites (#816 review)
SDK 2.0.1 checks the server host name in TWO places: Session.OpenAsync (after
the chain is trusted) and DefaultSessionFactory.CreateChannelAsync, against the
certificate in the GetEndpoints reply and before any security-mode test. opc-plc
sends a certificate on its None endpoint, so `connect -u
opc.tcp://10.100.0.35:50000 --validate-host-name` was refused on an
unauthenticated channel, where a host check proves nothing.

All three call sites now pass checkDomain only when the selected endpoint's
SecurityMode != None: OpcUaEndpointSelection.CheckDomainFor for the driver
(OpenSessionOnEndpointAsync, so the failover sweep too) and the AdminUI browser;
the same expression in Client.Shared ConnectToEndpointAsync (shared by
failover). New live tests: driver None + flag on vs :50000 reads; browser None +
flag on vs the NAT fixture browses; the secure + flag refusals still hold.

Docs: name both check sites; a zone-qualified IPv6 host never matches (FindDomain
compares the zone too), so link-local needs validateServerHostName=false; the
match is loose (first label alone, subject DC= parts count, no wildcards), which
caveats the CA recommendation.
2026-11-11 01:53:01 -05:00

130 lines
6.5 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
/// <summary>
/// Gitea #816 — does the driver check the server certificate's host name? Against the plain opc-plc fixture, whose
/// certificate names only its container hostname (e.g. <c>DNS:1fc1de3fcb21</c>) while the driver dials the docker
/// host's address, so a host check MUST fail if it runs at all.
/// <para>Before #816 the driver called the SDK's 8-argument <c>DefaultSessionFactory.CreateAsync</c>, which hard-codes
/// <c>checkDomain: false</c>: the mismatch was never noticed. Now <c>certificateValidation.validateServerHostName</c>
/// decides; unset keeps the old behaviour.</para>
/// <para>Each test trusts the server's certificate in a THROWAWAY PKI (the driver's <c>PkiRootOverride</c>) with
/// auto-accept OFF — auto-accept also accepts a host-name mismatch and would hide the check. The certificate is
/// taken from the server's own GetEndpoints answer: what is under test is the host check, not how trust was
/// established.</para>
/// </summary>
[Collection(OpcPlcCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc")]
public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposable
{
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), "otopcua-816-" + Guid.NewGuid().ToString("N"));
/// <inheritdoc />
public void Dispose()
{
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort temp cleanup */ }
}
private OpcUaClientDriverOptions SecureOptions(bool? validateHostName) => OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = validateHostName },
};
/// <summary>Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI.</summary>
private async Task TrustServerCertificateAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa #816 trust",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration(),
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
DisableHiResClock = true,
};
using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(sim.EndpointUrl), DiagnosticsMasks.None, ct);
var endpoints = await discovery.GetEndpointsAsync(default, ct);
var secure = endpoints.ToList().First(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
var der = secure.ServerCertificate.ToArray();
der.ShouldNotBeEmpty("the secure endpoint must carry the server certificate");
var certs = Path.Combine(_pkiRoot, "trusted", "certs");
Directory.CreateDirectory(certs);
await File.WriteAllBytesAsync(Path.Combine(certs, "opc-plc.der"), der, ct);
}
private async Task<OpcUaClientDriver> InitializeAsync(bool? validateHostName, string id, CancellationToken ct)
{
var drv = new OpcUaClientDriver(SecureOptions(validateHostName), driverInstanceId: id) { PkiRootOverride = _pkiRoot };
try
{
await drv.InitializeAsync("{}", ct);
return drv;
}
catch
{
await drv.DisposeAsync();
throw;
}
}
[Theory]
[InlineData(null)] // unset = the pre-#816 behaviour every existing deployment has
[InlineData(false)] // the explicit NAT / IP-addressed opt-out
public async Task Without_host_name_validation_a_trusted_certificate_for_another_host_connects(bool? validate)
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
await using var drv = await InitializeAsync(validate, $"opcua-816-off-{validate}", ct);
var snapshots = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted");
}
/// <summary>
/// Review of #816 (reproduced live): SDK 2.0.1 also checks the host at channel creation, against the certificate
/// in the GetEndpoints reply — which opc-plc sends on its None endpoint too — before any security-mode test. The
/// driver must not pass checkDomain for a None session, or the flag refuses an unsecured connect.
/// </summary>
[Fact]
public async Task With_host_name_validation_on_a_None_session_still_connects()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = true },
};
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-816-none") { PkiRootOverride = _pkiRoot };
await drv.InitializeAsync("{}", ct);
(await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct))[0].StatusCode.ShouldBe(0u);
}
[Fact]
public async Task With_host_name_validation_the_certificate_is_checked_against_the_configured_host()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
var ex = await Should.ThrowAsync<Exception>(() => InitializeAsync(true, "opcua-816-on", ct));
TestContext.Current.TestOutputHelper?.WriteLine($"refused: {ex}");
// The driver's failover aggregate keeps per-endpoint failures as text. The SDK message names the host it
// checked: the CONFIGURED one (the endpoint URL was rewritten to it), not whatever the server advertised.
var configuredHost = new Uri(sim.EndpointUrl).IdnHost;
var text = ex.ToString();
text.ShouldContain($"The domain '{configuredHost}' is not listed in the server certificate");
text.ShouldContain("validateServerHostName=false", Case.Sensitive, "the refusal must name the opt-out");
}
}