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.
This commit is contained in:
Joseph Doherty
2026-11-11 01:53:01 -05:00
parent ae91e6b7f7
commit 9aff90f884
15 changed files with 145 additions and 33 deletions
@@ -115,8 +115,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
configuredEndpoint,
updateBeforeConnect: false,
// Gitea #816: the same host-name policy as the deployed driver (the SDK's overload without this
// argument hard-codes false), checked against the configured host the endpoint was rewritten to.
checkDomain: opts.CertificateValidation.EffectiveValidateServerHostName,
// argument hard-codes false), checked against the configured host the endpoint was rewritten to —
// and, like the driver, never on a None endpoint.
checkDomain: OpcUaEndpointSelection.CheckDomainFor(
endpointDesc, opts.CertificateValidation.EffectiveValidateServerHostName),
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
@@ -251,9 +251,12 @@ public sealed record OpcUaCertificateValidationOptions
/// <item><c>false</c> = explicit opt-out — the NAT / port-publish / IP-addressed case, where the server's
/// certificate names its private host and the reachable address is something else.</item>
/// </list>
/// Only applies to secured sessions (a <c>None</c> channel validates no server certificate at all), and
/// <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> accepts a host-name mismatch like any other
/// certificate error.
/// Only applies to Sign / SignAndEncrypt endpoints: the driver and browser never pass the SDK's
/// <c>checkDomain</c> for a <c>None</c> endpoint, because SDK 2.0.1 would otherwise check at channel creation
/// against the GetEndpoints certificate even there. <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
/// accepts a host-name mismatch like any other certificate error. A zone-qualified IPv6 host
/// (<c>[fe80::1%eth0]</c>) never matches (the SDK compares the host WITH its zone against the certificate's
/// zone-less IP SANs), so a link-local endpoint needs <c>false</c>.
/// </summary>
public bool? ValidateServerHostName { get; init; }
@@ -46,6 +46,23 @@ public static class OpcUaEndpointSelection
return match;
}
/// <summary>
/// Gitea #816 — the <c>checkDomain</c> to hand the SDK for <paramref name="selected"/>: the operator's choice, but
/// never on a <see cref="MessageSecurityMode.None"/> endpoint. SDK 2.0.1 checks the host in TWO places — in
/// <c>DefaultSessionFactory.CreateChannelAsync</c>, against the certificate in the GetEndpoints reply, BEFORE any
/// security-mode test (opc-plc sends one even on its None endpoint), and in <c>Session.OpenAsync</c> once the
/// chain is trusted — so passing <c>true</c> on a None session refused it on a certificate the channel never
/// uses. A host-name check on an unauthenticated channel proves nothing.
/// </summary>
/// <param name="selected">The endpoint the session will open on.</param>
/// <param name="validateServerHostName">The configured (effective) host-name policy.</param>
/// <returns>The <c>checkDomain</c> argument.</returns>
public static bool CheckDomainFor(EndpointDescription selected, bool validateServerHostName)
{
ArgumentNullException.ThrowIfNull(selected);
return validateServerHostName && selected.SecurityMode != MessageSecurityMode.None;
}
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
/// <param name="policy">The driver security policy to map.</param>
/// <returns>The corresponding OPC UA security policy URI.</returns>
@@ -1061,12 +1061,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Gitea #816: the SDK's 8-argument CreateAsync hard-codes checkDomain:false, so before #816 the server
// certificate's host name was never checked. The check (when enabled) runs against endpoint.EndpointUrl —
// the CONFIGURED host, because SelectMatching rewrote the advertised one.
// the CONFIGURED host, because SelectMatching rewrote the advertised one — and never on a None endpoint
// (CheckDomainFor; the SDK would otherwise check at channel creation even there).
var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync(
appConfig,
endpoint,
false, // updateBeforeConnect
_options.CertificateValidation.EffectiveValidateServerHostName, // checkDomain (#816)
OpcUaEndpointSelection.CheckDomainFor( // checkDomain (#816)
selected, _options.CertificateValidation.EffectiveValidateServerHostName),
_options.SessionName,
(uint)_options.SessionTimeout.TotalMilliseconds,
identity,