fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
v2-ci / unit-tests (push) Has been cancelled
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:
+5
-3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user