feat(opcuaclient): opt-in server-certificate host-name check; NAT fixture keeps its PKI (#816, #818)
#816 finding: the SDK checks that the server certificate names the host only when a session is created with checkDomain:true. The driver, the AdminUI browser and Client.Shared all called the 8-argument DefaultSessionFactory.CreateAsync, which forwards checkDomain:false — so no host-name check ever ran (why a trusted NAT cert for *.invalid browsed fine over an IP in #813). When enabled the SDK (Session.OpenAsync -> ValidateDomains -> FindDomain) compares ConfiguredEndpoint.EndpointUrl.IdnHost — the CONFIGURED host, because OpcUaEndpointSelection/EndpointSelector rewrote the advertised one — against the cert's DNS/IP SANs; secured sessions only, and the auto-accept handler accepts a mismatch. Policy: certificateValidation.validateServerHostName, tri-state. Unset = OpcUaCertificateValidationOptions.DefaultValidateServerHostName = false, the pre-#816 behaviour, so nothing changes on upgrade; true = validate the configured host; false = explicit NAT/port-publish opt-out. Threaded into the driver, the AdminUI browser (same JSON) and the AdminUI form (a select that keeps "unset" unset), Client.Shared ConnectionSettings.ValidateServerHostName and the CLI --validate-host-name. A driver refusal names the opt-out. #818: the NAT fixture's /app/pki is on the opc-plc-nat-pki volume (verified: same thumbprint across --force-recreate), its PUBLIC cert is committed as Docker/nat/opc-plc-nat-server.der, and NatPublishedEndpointBrowseTests run every secure browse on a throwaway PKI (internal browser seam): refusal with nothing trusted, positive with the cert trusted (unset/false host check), and a BadCertificateHostNameInvalid refusal with the check on. The trusted cert is OPCUA_NAT_SERVER_CERT or the committed one (skip on thumbprint mismatch); OPCUA_NAT_SERVER_CERT_TRUSTED is retired. CI exports its throwaway NAT container's public cert. ServerHostNameValidationTests pin the driver against the plain opc-plc. MIN_EXECUTED not raised (needs a hosted run): +6 in v2-integration (440 -> 446), documented in scripts/ci/README.md.
This commit is contained in:
@@ -57,6 +57,13 @@ public abstract class CommandBase : ICommand
|
||||
[CommandOption("failover-urls", 'F', Description = "Comma-separated failover endpoint URLs for redundancy")]
|
||||
public string? FailoverUrls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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)")]
|
||||
public bool ValidateHostName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether verbose Serilog output should be enabled for troubleshooting.
|
||||
/// </summary>
|
||||
@@ -88,7 +95,8 @@ public abstract class CommandBase : ICommand
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SecurityMode = securityMode,
|
||||
AutoAcceptCertificates = true
|
||||
AutoAcceptCertificates = true,
|
||||
ValidateServerHostName = ValidateHostName
|
||||
};
|
||||
|
||||
return settings;
|
||||
|
||||
@@ -18,6 +18,7 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
string sessionName,
|
||||
uint sessionTimeoutMs,
|
||||
UserIdentity identity,
|
||||
bool validateServerHostName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var endpointConfig = EndpointConfiguration.Create(config);
|
||||
@@ -26,7 +27,10 @@ internal sealed class DefaultSessionFactory : ISessionFactory
|
||||
var created = await new Opc.Ua.Client.DefaultSessionFactory(ClientTelemetry.Context).CreateAsync(
|
||||
config,
|
||||
configuredEndpoint,
|
||||
false,
|
||||
false, // updateBeforeConnect
|
||||
// #816: the overload without checkDomain hard-codes false. The endpoint URL here is the operator's host
|
||||
// (EndpointSelector rewrote the advertised one), so that is the host the certificate is checked against.
|
||||
validateServerHostName, // checkDomain
|
||||
sessionName,
|
||||
sessionTimeoutMs,
|
||||
identity,
|
||||
|
||||
@@ -15,6 +15,9 @@ internal interface ISessionFactory
|
||||
/// <param name="sessionName">The session name.</param>
|
||||
/// <param name="sessionTimeoutMs">Session timeout in milliseconds.</param>
|
||||
/// <param name="identity">The user identity.</param>
|
||||
/// <param name="validateServerHostName">
|
||||
/// The SDK's <c>checkDomain</c>: require the server certificate to name the endpoint URL's host (#816).
|
||||
/// </param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A session adapter wrapping the created session.</returns>
|
||||
Task<ISessionAdapter> CreateSessionAsync(
|
||||
@@ -23,5 +26,6 @@ internal interface ISessionFactory
|
||||
string sessionName,
|
||||
uint sessionTimeoutMs,
|
||||
UserIdentity identity,
|
||||
bool validateServerHostName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -40,6 +40,15 @@ public sealed class ConnectionSettings
|
||||
/// </summary>
|
||||
public bool AutoAcceptCertificates { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool ValidateServerHostName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the certificate store. Defaults to <see cref="string.Empty"/>; the
|
||||
/// consuming application configuration factory resolves the canonical path via
|
||||
|
||||
@@ -777,6 +777,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
SecurityMode = settings.SecurityMode,
|
||||
SessionTimeoutSeconds = settings.SessionTimeoutSeconds,
|
||||
AutoAcceptCertificates = settings.AutoAcceptCertificates,
|
||||
ValidateServerHostName = settings.ValidateServerHostName,
|
||||
CertificateStorePath = settings.CertificateStorePath,
|
||||
Username = settings.Username,
|
||||
Password = settings.Password
|
||||
@@ -794,7 +795,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
return await _sessionFactory.CreateSessionAsync(config, endpoint, "OtOpcUaClient", sessionTimeoutMs, identity,
|
||||
ct);
|
||||
settings.ValidateServerHostName, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
+21
-3
@@ -35,6 +35,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
|
||||
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
|
||||
private readonly ISecretResolver? _secretResolver;
|
||||
private readonly string? _pkiRootOverride;
|
||||
|
||||
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
|
||||
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
|
||||
@@ -49,6 +50,18 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
_secretResolver = secretResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam (Gitea #818): a browser whose PKI lives under <paramref name="pkiRoot"/> instead of the shared
|
||||
/// <c>%LocalAppData%/OtOpcUa/adminui-browse-pki</c>, so an integration test can trust a fixture's public
|
||||
/// certificate in a throwaway store without touching the operator's browse trust list.
|
||||
/// </summary>
|
||||
/// <param name="pkiRoot">Directory holding the <c>own</c>/<c>trusted</c>/<c>issuers</c>/<c>rejected</c> stores.</param>
|
||||
internal OpcUaClientDriverBrowser(string pkiRoot) : this()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pkiRoot);
|
||||
_pkiRootOverride = pkiRoot;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
// Sourced from the shared DriverTypeNames set rather than the hand-written literal
|
||||
// this line used to carry. The value is unchanged; the constant is what
|
||||
@@ -82,7 +95,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
"driver's PKI store instead.",
|
||||
endpoint);
|
||||
|
||||
var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var app = await BuildBrowseAppConfigurationAsync(_pkiRootOverride, cancellationToken).ConfigureAwait(false);
|
||||
ownedApplication = app;
|
||||
var appConfig = app.ApplicationConfiguration!;
|
||||
|
||||
@@ -101,6 +114,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
appConfig,
|
||||
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,
|
||||
sessionName: "OtOpcUa AdminUI Browse",
|
||||
(uint)opts.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
@@ -143,8 +159,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
/// the runtime driver (<c>%LocalAppData%/OtOpcUa/adminui-browse-pki/</c>). Browse
|
||||
/// trust decisions don't leak into the deployed driver's cert store.
|
||||
/// </summary>
|
||||
/// <param name="pkiRootOverride">Test-only PKI root; <c>null</c> = the shared browse PKI.</param>
|
||||
/// <param name="ct">Cancellation token for the configuration build.</param>
|
||||
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(CancellationToken ct)
|
||||
private static async Task<ApplicationInstance> BuildBrowseAppConfigurationAsync(
|
||||
string? pkiRootOverride, CancellationToken ct)
|
||||
{
|
||||
// The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK
|
||||
// internals off any logging provider.
|
||||
@@ -154,7 +172,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
ApplicationType = ApplicationType.Client,
|
||||
};
|
||||
|
||||
var pkiRoot = Path.Combine(
|
||||
var pkiRoot = pkiRootOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "adminui-browse-pki");
|
||||
|
||||
|
||||
+1
@@ -19,5 +19,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+29
@@ -231,6 +231,35 @@ public sealed record OpcUaCertificateValidationOptions
|
||||
/// operator-managed CRL set. <c>null</c>/empty (default) = <c>%LocalAppData%/OtOpcUa/pki/issuers</c>.
|
||||
/// </summary>
|
||||
public string? IssuerStorePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value an unset <see cref="ValidateServerHostName"/> resolves to. <c>false</c> keeps the behaviour every
|
||||
/// deployment had before Gitea #816 (the SDK never checked the host name for this driver family).
|
||||
/// </summary>
|
||||
public const bool DefaultValidateServerHostName = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #816 — whether the server certificate must list the host the operator CONFIGURED (the endpoint URL's
|
||||
/// host, which the shared <see cref="OpcUaEndpointUrlRewriter"/> keeps in place of the server-advertised one)
|
||||
/// among its DNS / IP subject-alternative names. A mismatch refuses the session with
|
||||
/// <c>BadCertificateHostNameInvalid</c>.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>null</c> (default) = <see cref="DefaultValidateServerHostName"/> (off).</item>
|
||||
/// <item><c>true</c> = validate. Worth it 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); adds little when the
|
||||
/// server's own self-signed certificate is pinned in the trusted-peer store.</item>
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public bool? ValidateServerHostName { get; init; }
|
||||
|
||||
/// <summary><see cref="ValidateServerHostName"/> with the default applied.</summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public bool EffectiveValidateServerHostName => ValidateServerHostName ?? DefaultValidateServerHostName;
|
||||
}
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
|
||||
@@ -581,7 +581,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}{HostNameRefusalHint(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +726,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
private void WriteLifecycleHealth(long generation, Func<DriverHealth, DriverHealth> next) =>
|
||||
UpdateHealth(generation, next);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam (Gitea #816): when set, the PKI stores live under this directory instead of the shared
|
||||
/// <c>%LocalAppData%/OtOpcUa/pki</c>, so an integration test can trust a fixture's certificate in a throwaway
|
||||
/// store with <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/> off (auto-accept also accepts a
|
||||
/// host-name mismatch, which would hide the check under test). Set before <c>InitializeAsync</c>.
|
||||
/// </summary>
|
||||
internal string? PkiRootOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
|
||||
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
|
||||
@@ -747,7 +755,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
};
|
||||
|
||||
certValidation.Application = app;
|
||||
var pkiRoot = Path.Combine(
|
||||
var pkiRoot = PkiRootOverride ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
|
||||
@@ -1051,10 +1059,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
// 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.
|
||||
var session = await new DefaultSessionFactory(_sdkTelemetry).CreateAsync(
|
||||
appConfig,
|
||||
endpoint,
|
||||
false, // updateBeforeConnect
|
||||
_options.CertificateValidation.EffectiveValidateServerHostName, // checkDomain (#816)
|
||||
_options.SessionName,
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
@@ -1085,6 +1097,19 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
return OpcUaEndpointSelection.SelectMatching(all.ToList(), endpointUrl, policy, mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #816 — the operator-facing remedy appended to a <c>BadCertificateHostNameInvalid</c> connect failure (only
|
||||
/// reachable with <c>certificateValidation.validateServerHostName</c> on). The SDK's own text names the host it
|
||||
/// checked but not the knob.
|
||||
/// </summary>
|
||||
/// <param name="ex">The per-endpoint connect failure.</param>
|
||||
/// <returns>The hint, or an empty string for any other failure.</returns>
|
||||
internal static string HostNameRefusalHint(Exception ex) =>
|
||||
ex is ServiceResultException sre && sre.StatusCode == StatusCodes.BadCertificateHostNameInvalid
|
||||
? " (the server certificate does not name the configured endpoint host; add that host to the certificate, " +
|
||||
"connect by a name it lists, or — behind NAT / a port publish — set certificateValidation.validateServerHostName=false)"
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>Delegates to the shared <see cref="OpcUaEndpointUrlRewriter.Rewrite"/> (Gitea #676 / #806).</summary>
|
||||
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
|
||||
/// <param name="configuredUrl">The URL the driver was configured with (the one GetEndpoints was sent to).</param>
|
||||
|
||||
+2
@@ -28,6 +28,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
|
||||
<!-- Gitea #816: the host-name tests trust the fixture certificate in a throwaway PKI (PkiRootOverride). -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests"/>
|
||||
<!-- Gitea #656: the native-alarm re-statement end-to-end tests drive this driver into the real DriverHostActor. -->
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Runtime.Tests"/>
|
||||
<!-- Gitea #670 review M1: the wire test deletes the driver's subscription server-side through its own session. -->
|
||||
|
||||
+23
@@ -192,6 +192,15 @@
|
||||
</div>
|
||||
<div class="form-text">Revoked certificates are always rejected.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Server host-name check</label>
|
||||
<InputSelect @bind-Value="_form.OpcUa.ValidateServerHostName" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
|
||||
<option value="">Default (off)</option>
|
||||
<option value="true">Validate configured host</option>
|
||||
<option value="false">Off (NAT / port publish)</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Secured sessions only: the server certificate must name the endpoint host you configured.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Issuer store / CRL directory</label>
|
||||
<InputText @bind-Value="_form.OpcUa.IssuerStorePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
|
||||
@@ -349,6 +358,8 @@
|
||||
public int SubscriptionPriority { get; set; }
|
||||
public bool RejectUnknownRevocationStatus { get; set; }
|
||||
public string? IssuerStorePath { get; set; }
|
||||
// #816 tri-state: "" = unset (the driver default) so the form never re-emits a concrete default; "true"/"false".
|
||||
public string ValidateServerHostName { get; set; } = "";
|
||||
public string? DiscoveryUrl { get; set; }
|
||||
public string? DiscoveryServerUri { get; set; }
|
||||
|
||||
@@ -381,6 +392,12 @@
|
||||
SubscriptionPriority = r.SubscriptionPriority,
|
||||
RejectUnknownRevocationStatus = r.CertificateValidation.RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = r.CertificateValidation.IssuerStorePath,
|
||||
ValidateServerHostName = r.CertificateValidation.ValidateServerHostName switch
|
||||
{
|
||||
true => "true",
|
||||
false => "false",
|
||||
null => "",
|
||||
},
|
||||
DiscoveryUrl = r.DiscoveryUrl,
|
||||
DiscoveryServerUri = r.DiscoveryServerUri,
|
||||
};
|
||||
@@ -417,6 +434,12 @@
|
||||
{
|
||||
RejectUnknownRevocationStatus = RejectUnknownRevocationStatus,
|
||||
IssuerStorePath = string.IsNullOrWhiteSpace(IssuerStorePath) ? null : IssuerStorePath,
|
||||
ValidateServerHostName = ValidateServerHostName switch
|
||||
{
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => null,
|
||||
},
|
||||
},
|
||||
DiscoveryUrl = string.IsNullOrWhiteSpace(DiscoveryUrl) ? null : DiscoveryUrl,
|
||||
DiscoveryServerUri = string.IsNullOrWhiteSpace(DiscoveryServerUri) ? null : DiscoveryServerUri,
|
||||
|
||||
Reference in New Issue
Block a user