fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
v2-ci / unit-tests (push) Has been cancelled

ConfigurationBinder appends configured collection items to an existing list, so
the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op:
every deployment that listed only the secure profiles still advertised an
unsigned/unencrypted None endpoint.

EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles()
applies the unchanged three-profile default only when nothing is configured.
A set-but-empty list fails startup validation and BuildSecurityPolicies no
longer falls back to a None endpoint on empty input (it throws). Sweep of all
options classes found no other non-empty pre-filled collection default.

Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a
booted-server wire test (no None endpoint, None connection refused with
BadSecurityPolicyRejected, secure connection works; default case is the control).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 11:38:41 -05:00
parent 1172687d4b
commit a83bf1fa27
11 changed files with 372 additions and 31 deletions
@@ -25,10 +25,12 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
// lmxopcua#879: null (not configured) means "use the default three profiles" and is valid; a list that is
// present but empty is an error — it must never silently mean "None only" or "everything".
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
// IReadOnlyCollection<T>, so it can't bind to MinCount's IReadOnlyCollection<T> parameter
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
// IReadOnlyCollection<T>, so ToList() bridges to MinCount's parameter (count + message preserved).
if (o.EnabledSecurityProfiles is not null)
builder.MinCount(o.EnabledSecurityProfiles.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
// #876: discovery registration is opt-in; when configured, the URL must be an absolute opc.tcp URI and the
// interval positive (0 would silently disable the registration that was asked for).
@@ -79,18 +79,54 @@ public sealed class OpcUaApplicationHostOptions
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
/// descriptor per profile and clients choose at session open. Default = all three
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
/// typically drop None.
/// Transport-security profiles exposed by the server (<c>OpcUa:EnabledSecurityProfiles</c>); the SDK
/// publishes one endpoint descriptor per profile and clients choose at session open.
/// </summary>
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
/// <remarks>
/// <para>
/// <b>Replace semantics (lmxopcua#879).</b> This is <see langword="null"/> by default and the
/// three-profile default (<see cref="DefaultSecurityProfiles"/>: <c>None</c> +
/// <c>Basic256Sha256Sign</c> + <c>Basic256Sha256SignAndEncrypt</c>) is applied by
/// <see cref="ResolveEnabledSecurityProfiles"/> only when nothing was configured. It must NOT be
/// pre-filled: <c>ConfigurationBinder</c> appends configured collection items to an existing list
/// instead of replacing it, so a pre-filled default made it impossible to drop <c>None</c>.
/// </para>
/// <para>
/// <b>Empty list.</b> A key that is absent, or an array with no items (<c>[]</c> binds to nothing),
/// means "not configured" and yields the default. A list that is non-null but empty (only
/// reachable from code, or an env value the binder materialises as an empty list) is a
/// configuration error: <c>OpcUaApplicationHostOptionsValidator</c> fails startup and
/// <see cref="ResolveEnabledSecurityProfiles"/> throws. It never falls back to <c>None</c>.
/// </para>
/// </remarks>
public IList<OpcUaSecurityProfile>? EnabledSecurityProfiles { get; set; }
/// <summary>
/// The profiles applied when <see cref="EnabledSecurityProfiles"/> is not configured — all three
/// baseline profiles, unchanged from before lmxopcua#879. Production deployments configure the list
/// explicitly (without <c>None</c>) to drop the unsecured endpoint.
/// </summary>
public static IReadOnlyList<OpcUaSecurityProfile> DefaultSecurityProfiles { get; } = new[]
{
OpcUaSecurityProfile.None,
OpcUaSecurityProfile.Basic256Sha256Sign,
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
};
/// <summary>
/// The profiles the server actually publishes: the configured list when one was given, otherwise
/// <see cref="DefaultSecurityProfiles"/>.
/// </summary>
/// <exception cref="InvalidOperationException">The list was set but is empty.</exception>
public IReadOnlyList<OpcUaSecurityProfile> ResolveEnabledSecurityProfiles()
{
if (EnabledSecurityProfiles is null) return DefaultSecurityProfiles;
if (EnabledSecurityProfiles.Count == 0)
throw new InvalidOperationException(
"OpcUa:EnabledSecurityProfiles is set but empty; list at least one profile or omit the key to use the default.");
return EnabledSecurityProfiles.ToList();
}
/// <summary>
/// When true, unknown client certificates are auto-added to the trusted store on first
/// connection. Convenient for dev; should be false in production (operators promote via
@@ -635,7 +671,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
DisableSdkAuthenticationLockout(serverConfig);
ApplyDiscoveryRegistration(serverConfig, _options);
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.ResolveEnabledSecurityProfiles()).ToArray();
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
var config = new ApplicationConfiguration(_telemetry)
@@ -779,14 +815,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
};
}
// lmxopcua#879: an empty profile list used to fall back to a single None endpoint. A misconfiguration must
// never silently widen the transport to unsecured, so it is an error (the host validator rejects it first).
if (!any)
{
yield return new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
};
}
throw new InvalidOperationException(
"No OPC UA security profile is enabled; OpcUa:EnabledSecurityProfiles must list at least one.");
}
/// <summary>