diff --git a/.claude/rules/drivers.md b/.claude/rules/drivers.md
index ecdbfcca6..5de204ad0 100644
--- a/.claude/rules/drivers.md
+++ b/.claude/rules/drivers.md
@@ -292,8 +292,11 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
`checkDomain: true`; its 8-arg `DefaultSessionFactory.CreateAsync` hard-codes `false`, so driver, AdminUI browser and
Client.Shared never checked before #816. Now `certificateValidation.validateServerHostName` (tri-state, unset = off =
unchanged; AdminUI keeps it unset) / CLI `--validate-host-name`. It checks the **configured** host (the rewritten
- `ConfiguredEndpoint.EndpointUrl`), secured sessions only, and auto-accept accepts a mismatch. Any new session-create
- call site must pass the flag — the overload without it silently disables the check. The rewriter keeps an IPv6
+ `ConfiguredEndpoint.EndpointUrl`) and auto-accept accepts a mismatch. ⚠️ The SDK checks in TWO places —
+ `DefaultSessionFactory.CreateChannelAsync` (GetEndpoints cert, BEFORE any security-mode test, so it fired on `None`)
+ and `Session.OpenAsync` — so pass `checkDomain` only for a non-`None` endpoint (`OpcUaEndpointSelection.CheckDomainFor`).
+ Any new session-create call site must do that — the overload without the argument silently disables the check.
+ Zone-qualified IPv6 hosts never match (zone compared); the match is loose (first label alone counts). The rewriter keeps an IPv6
zone index verbatim (#819; .NET `Uri` drops it from every re-serialisation). `docs/drivers/OpcUaClient.md`.
- **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing
with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device
diff --git a/docs/Client.CLI.md b/docs/Client.CLI.md
index 6cb9eaeec..5f71be86b 100644
--- a/docs/Client.CLI.md
+++ b/docs/Client.CLI.md
@@ -39,7 +39,7 @@ All commands accept these options:
| `-P` / `--password` | Password for `UserName` token authentication |
| `-S` / `--security` | Transport security mode: `none`, `sign`, `encrypt`, `signandencrypt` (default: `none`) |
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
-| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Secured connections only; off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits |
+| `--validate-host-name` | Require the server certificate to list the `--url` host (#816). Only with `-S sign`/`encrypt` — ignored for `-S none` (the SDK would otherwise check the GetEndpoints certificate even on an unsecured channel); off by default (the CLI's long-standing behaviour). The CLI auto-accepts untrusted certificates, so this checks a misconfiguration, not trust. Leave off behind NAT / when connecting by an IP the certificate omits / for a zone-qualified IPv6 host |
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
**`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and
diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md
index 9ed288728..fa4c18ccc 100644
--- a/docs/drivers/OpcUaClient.md
+++ b/docs/drivers/OpcUaClient.md
@@ -137,18 +137,37 @@ AdminUI browser (`OpcUaClientDriverBrowser.OpenAsync`) and Client.Shared (`Defau
forwards `checkDomain: false`. So **no host-name check ever ran**; trust was the trusted-peer / issuer store alone.
That is why, in #813, a trusted NAT certificate for `otopcua-opc-plc-nat-private.invalid` browsed fine over an IP.
-Where the check lives when enabled: `Session.OpenAsync` → after `CertificateManager.ValidateAsync` accepts the chain,
-`if (checkDomain) CertificateValidationExtensions.ValidateDomains(validator, serverCert, session.ConfiguredEndpoint)`
-→ `CertificateValidationCore.ValidateDomains` → `CertificateValidationHelpers.FindDomain`, which compares
-`ConfiguredEndpoint.EndpointUrl.IdnHost` with the certificate's DNS / IP SANs (IP literals normalised; a `localhost`
-/ `127.0.0.1` / `::1` URL is compared as the machine's own host name + FQDN). A miss is `BadCertificateHostNameInvalid`
-("The domain '…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook
-first. Consequences:
+Where the check lives when enabled — **two** places in SDK 2.0.1, both calling
+`CertificateValidationExtensions.ValidateDomains` → `CertificateValidationCore.ValidateDomains` →
+`CertificateValidationHelpers.FindDomain`:
+
+1. `DefaultSessionFactory.CreateChannelAsync` — before the channel opens, against the certificate in the
+ **GetEndpoints reply** (`endpoint.Description.ServerCertificate`), whenever that reply carries one and **before any
+ security-mode test**. opc-plc sends its certificate on its `None` endpoint too, so `checkDomain: true` refused a
+ `None` connect (found in review, reproduced against `:50000`).
+2. `Session.OpenAsync` — after `CertificateManager.ValidateAsync` accepts the chain the server returned in
+ CreateSession (only when the channel or the user-token policy needs the server certificate).
+
+`FindDomain` compares `ConfiguredEndpoint.EndpointUrl.IdnHost` with the names `X509Utils.GetDomainsFromCertificate`
+returns (DNS / IP SANs, and the subject's `DC=` parts); IP literals are normalised, and a `localhost` / `127.0.0.1` /
+`::1` URL is compared as the machine's own host name + FQDN. A miss is `BadCertificateHostNameInvalid` ("The domain
+'…' is not listed in the server certificate."), passed to the certificate manager's `AcceptError` hook first.
+Consequences:
- **It checks the CONFIGURED host.** `ConfiguredEndpoint.EndpointUrl` is the URL `OpcUaEndpointSelection` /
`EndpointSelector` rewrote, so the advertised host the server named itself never takes part.
-- **Secured sessions only** — the block runs only when the channel (or the user-token policy) needs the server
- certificate; a `None` session validates no certificate at all.
+- **Sign / SignAndEncrypt endpoints only — enforced here, not by the SDK.** Because of site 1, all three call sites
+ pass `checkDomain` only when the selected endpoint's `SecurityMode != None` (`OpcUaEndpointSelection.CheckDomainFor`
+ for the driver and the browser, the same expression in Client.Shared's `ConnectToEndpointAsync`; both cover the
+ failover sweep, which goes through the same per-endpoint open). A host-name check on an unauthenticated channel
+ proves nothing.
+- **It is a loose match.** For a DNS host the SDK also accepts a match on the **first label alone**:
+ `plc1.site-a.corp` passes a certificate whose only SAN is `PLC1` (case-insensitive), and so would
+ `plc1.site-b.corp`. There is no wildcard support (`*.site-a.corp` matches nothing), and the subject's `DC=`
+ components count as names.
+- **A zone-qualified IPv6 host never passes.** `FindDomain` compares the host *with* its zone (`fe80::1%eth0`; macOS
+ normalises `%en0` to `%14`) against the certificate's zone-less IP SANs, so a link-local endpoint (#819) needs
+ `validateServerHostName: false`. The repo does not fork the SDK check to strip the zone.
- **`autoAcceptCertificates` accepts a mismatch too** (the driver's auto-accept `AcceptError` handler returns `true`
for everything but revocation). The Client CLI always auto-accepts.
- Test Connect (`OpcUaClientDriverProbe`) only calls GetEndpoints on an unsecured discovery channel — no session, so no
@@ -164,7 +183,9 @@ the browser reads the same JSON):
| `false` | Explicit opt-out — the NAT / container port-publish / connect-by-an-IP-the-cert-omits case. |
When to turn it on: it earns its keep when trust comes from a **CA** (a trusted issuer vouches for every certificate
-it signed, so only the host check ties the certificate to *this* server). With the server's own self-signed
+it signed, so only the host check ties the certificate to *this* server) — within the limits above: a
+first-label-only match means a certificate issued as `PLC1` for one site is accepted for `plc1` at every site, so give
+certificates fully-qualified, site-unique names. With the server's own self-signed
certificate pinned in the trusted-peer store it adds little — the pin already identifies the server. Client
CLI: `--validate-host-name` (`ConnectionSettings.ValidateServerHostName`, default `false`). Pinned by
`ServerHostNameValidationTests` (driver vs. the plain opc-plc, whose certificate names only its container id) and
@@ -437,7 +458,7 @@ also refuses them explicitly — defence in depth (#277), not a behaviour change
|---|---|---|
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
-| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Secured sessions only; accepted anyway under `autoAcceptCertificates`. `false` = explicit NAT opt-out. See "Server certificate host name" above. |
+| `validateServerHostName` | `null` (= off) | #816: require the server certificate to name the **configured** endpoint host (`BadCertificateHostNameInvalid` otherwise). Sign / SignAndEncrypt endpoints only (never passed for `None`); accepted anyway under `autoAcceptCertificates`; loose (first-label) match; never matches a zone-qualified IPv6 host. `false` = explicit NAT / link-local opt-out. See "Server certificate host name" above. |
### Subscription tuning (#273)
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs
index ed95be7c6..8bb02aca5 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/CommandBase.cs
@@ -61,7 +61,7 @@ public abstract class CommandBase : ICommand
/// Gets a value indicating whether the server certificate must name the host given in (#816).
///
[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; }
///
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs
index 952bda84b..233242dd9 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/ConnectionSettings.cs
@@ -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 BadCertificateHostNameInvalid. Defaults to false, 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 , 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).
///
public bool ValidateServerHostName { get; set; }
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
index 012a3c261..8ea477560 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/OpcUaClientService.cs
@@ -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
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
index f80b054bf..cb318298c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs
@@ -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,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs
index ea9824cc0..df2c4ca34 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs
@@ -251,9 +251,12 @@ public sealed record OpcUaCertificateValidationOptions
/// false = 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.
///
- /// Only applies to secured sessions (a None channel validates no server certificate at all), and
- /// 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
+ /// checkDomain for a None endpoint, because SDK 2.0.1 would otherwise check at channel creation
+ /// against the GetEndpoints certificate even there.
+ /// accepts a host-name mismatch like any other certificate error. A zone-qualified IPv6 host
+ /// ([fe80::1%eth0]) never matches (the SDK compares the host WITH its zone against the certificate's
+ /// zone-less IP SANs), so a link-local endpoint needs false.
///
public bool? ValidateServerHostName { get; init; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs
index f29898288..8b2d2fe70 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaEndpointSelection.cs
@@ -46,6 +46,23 @@ public static class OpcUaEndpointSelection
return match;
}
+ ///
+ /// Gitea #816 — the checkDomain to hand the SDK for : the operator's choice, but
+ /// never on a endpoint. SDK 2.0.1 checks the host in TWO places — in
+ /// DefaultSessionFactory.CreateChannelAsync, against the certificate in the GetEndpoints reply, BEFORE any
+ /// security-mode test (opc-plc sends one even on its None endpoint), and in Session.OpenAsync once the
+ /// chain is trusted — so passing true on a None session refused it on a certificate the channel never
+ /// uses. A host-name check on an unauthenticated channel proves nothing.
+ ///
+ /// The endpoint the session will open on.
+ /// The configured (effective) host-name policy.
+ /// The checkDomain argument.
+ public static bool CheckDomainFor(EndpointDescription selected, bool validateServerHostName)
+ {
+ ArgumentNullException.ThrowIfNull(selected);
+ return validateServerHostName && selected.SecurityMode != MessageSecurityMode.None;
+ }
+
/// Convert a driver to the OPC UA policy URI.
/// The driver security policy to map.
/// The corresponding OPC UA security policy URI.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
index c39baf985..acba83198 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs
@@ -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,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
index d080f2dc9..115cb5eb7 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
@@ -199,7 +199,7 @@
-
Secured sessions only: the server certificate must name the endpoint host you configured.
+
Sign / SignAndEncrypt only (never on None): the server certificate must name the endpoint host you configured. Off for link-local IPv6.
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
index 104f20fbf..c021b3966 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/OpcUaClientServiceTests.cs
@@ -56,21 +56,28 @@ public class OpcUaClientServiceTests : IDisposable
}
///
- /// Gitea #816: the host-name check reaches the session factory (the SDK's checkDomain) — off by default, the
- /// pre-#816 behaviour — and survives the per-endpoint settings copy the connect path makes.
+ /// Gitea #816: the host-name check reaches the session factory (the SDK's checkDomain) on a SECURED endpoint — off
+ /// by default, the pre-#816 behaviour — and survives the per-endpoint settings copy the connect path makes. On a
+ /// None endpoint it is never passed: SDK 2.0.1 would check the GetEndpoints certificate at channel creation even
+ /// there (review of #816, reproduced against opc-plc).
///
[Theory]
- [InlineData(null)]
- [InlineData(true)]
- [InlineData(false)]
- public async Task ConnectAsync_threads_ValidateServerHostName_to_the_session(bool? validate)
+ [InlineData(SecurityMode.Sign, null, false)]
+ [InlineData(SecurityMode.Sign, true, true)]
+ [InlineData(SecurityMode.SignAndEncrypt, true, true)]
+ [InlineData(SecurityMode.Sign, false, false)]
+ [InlineData(SecurityMode.None, true, false)]
+ [InlineData(SecurityMode.None, null, false)]
+ public async Task ConnectAsync_threads_ValidateServerHostName_to_secured_sessions_only(
+ SecurityMode mode, bool? validate, bool expectedCheckDomain)
{
var settings = ValidSettings();
+ settings.SecurityMode = mode;
if (validate is { } v) settings.ValidateServerHostName = v;
await _service.ConnectAsync(settings, TestContext.Current.CancellationToken);
- _sessionFactory.LastValidateServerHostName.ShouldBe(validate ?? false);
+ _sessionFactory.LastValidateServerHostName.ShouldBe(expectedCheckDomain);
_configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs
index 5e66a574a..dcb736d38 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/NatPublishedEndpointBrowseTests.cs
@@ -190,6 +190,21 @@ public sealed class NatPublishedEndpointBrowseTests : IDisposable
children.Count.ShouldBeGreaterThan(0);
}
+ ///
+ /// Review of #816: with the host-name check ON, an unsecured browse still opens although the NAT certificate (sent
+ /// in the GetEndpoints reply even for the None endpoint) names another host — the check never runs on None.
+ ///
+ [Fact]
+ public async Task Host_name_validation_does_not_apply_to_a_None_browse()
+ {
+ var endpoint = RequireFixture();
+ var ct = TestContext.Current.CancellationToken;
+ await AssertAdvertisesUnreachableAddressAsync(endpoint, ct);
+
+ await using var session = await Browser().OpenAsync(Json(endpoint, validateHostName: true), ct);
+ (await session.RootAsync(ct)).Count.ShouldBeGreaterThan(0);
+ }
+
[Fact]
public async Task Unreachable_configured_endpoint_still_fails_cleanly()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs
index 1687d7d06..6c84ec382 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ServerHostNameValidationTests.cs
@@ -89,6 +89,27 @@ public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposab
snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted");
}
+ ///
+ /// 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.
+ ///
+ [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()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs
index a4c27f1b7..8208ab004 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientServerHostNameOptionTests.cs
@@ -43,6 +43,21 @@ public sealed class OpcUaClientServerHostNameOptionTests
json.ShouldNotContain("Effective");
}
+ ///
+ /// Review of #816: SDK 2.0.1 also checks the host in DefaultSessionFactory.CreateChannelAsync, against the
+ /// GetEndpoints certificate and before any security-mode test, so checkDomain is never passed for a None endpoint.
+ ///
+ [Theory]
+ [InlineData(MessageSecurityMode.None, true, false)]
+ [InlineData(MessageSecurityMode.None, false, false)]
+ [InlineData(MessageSecurityMode.Sign, true, true)]
+ [InlineData(MessageSecurityMode.SignAndEncrypt, true, true)]
+ [InlineData(MessageSecurityMode.SignAndEncrypt, false, false)]
+ public void CheckDomain_is_passed_only_for_secured_endpoints(MessageSecurityMode mode, bool validate, bool expected)
+ {
+ OpcUaEndpointSelection.CheckDomainFor(new EndpointDescription { SecurityMode = mode }, validate).ShouldBe(expected);
+ }
+
[Fact]
public void A_host_name_refusal_names_the_opt_out()
{