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() {