9aff90f884
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.
130 lines
6.5 KiB
C#
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");
|
|
}
|
|
}
|