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
@@ -61,7 +61,7 @@ public abstract class CommandBase : ICommand
/// Gets a value indicating whether the server certificate must name the host given in <see cref="Url" /> (#816).
/// </summary>
[CommandOption("validate-host-name",
Description = "Require the server certificate to list the --url host (secured connections only; off by default — leave off behind NAT)")]
Description = "Require the server certificate to list the --url host (sign/encrypt only, never with --security none; off by default — leave off behind NAT)")]
public bool ValidateHostName { get; init; }
/// <summary>
@@ -44,8 +44,10 @@ public sealed class ConnectionSettings
/// Gitea #816 — require the server certificate to list the host of the URL being connected (the operator's
/// host: discovery keeps it in place of the server-advertised one) among its subject-alternative names;
/// a mismatch fails the connect with <c>BadCertificateHostNameInvalid</c>. Defaults to <c>false</c>, the
/// behaviour before #816 (the SDK overload used never checked). Secured connections only. Leave it off behind
/// NAT / a port publish / when connecting by an IP the certificate does not name.
/// behaviour before #816 (the SDK overload used never checked). Applied to Sign / SignAndEncrypt endpoints only
/// — never passed for <see cref="Models.SecurityMode.None"/>, where SDK 2.0.1 would check the GetEndpoints
/// certificate at channel creation anyway. Leave it off behind NAT / a port publish / when connecting by an IP
/// the certificate does not name / for a zone-qualified IPv6 host (the SDK compares the zone too).
/// </summary>
public bool ValidateServerHostName { get; set; }
@@ -794,8 +794,12 @@ public sealed class OpcUaClientService : IOpcUaClientService
: new UserIdentity();
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
// #816: never on a None endpoint — SDK 2.0.1 also checks the host at channel creation against the
// GetEndpoints certificate, before any security-mode test, and on an unauthenticated channel that proves
// nothing (same rule as OpcUaEndpointSelection.CheckDomainFor for the driver and the AdminUI browser).
var checkDomain = settings.ValidateServerHostName && endpoint.SecurityMode != MessageSecurityMode.None;
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
settings.ValidateServerHostName, ct);
checkDomain, ct);
}
catch
{