OpcUa:EnabledSecurityProfiles cannot drop None: the binder appends to the pre-filled default list, so the None endpoint is always advertised #879

Closed
opened 2026-11-12 10:04:58 -05:00 by dohertj2 · 1 comment
Owner

Found by the #800 Windows service gate on windev, 2026-11-12. The bug is in both the SDK 1.5 baseline efb9197a and SDK 2 master 438484fd, so it is not an SDK 2 regression.

Symptom: the node was configured with only the secure profiles:

OpcUa__EnabledSecurityProfiles__0=Basic256Sha256SignAndEncrypt
OpcUa__EnabledSecurityProfiles__1=Basic256Sha256Sign

The OtOpcUaHost service still advertises a None/None endpoint, with a UserName token policy. GetEndpoints from a probe on the box returned None, Sign and SignAndEncrypt. docs/security.md §hardening says to "drop None from EnabledSecurityProfiles" and shows this exact JSON list, but that has no effect.

Cause: OpcUaApplicationHostOptions.EnabledSecurityProfiles is initialised to the default list [None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]. ConfigurationBinder appends the configured entries to an existing list instead of replacing it, so the configured profiles are added after the defaults and None is never removed. The same happens with the JSON array form in appsettings, because the binder treats both forms the same way.

Repro (no server needed; console project referencing the Host project, at master 438484fd):

var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
    ["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign",
    ["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt",
}).Build();
var options = new OpcUaApplicationHostOptions();
config.GetSection("OpcUa").Bind(options);
Console.WriteLine(string.Join(", ", options.EnabledSecurityProfiles));
// None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt

The repro is in scripts/validation/opcua-windows-service/BindRepro/. The wire evidence is in #800's results (endpoints-advertised in every checks-*.json).

Impact:

  • Every deployment that "dropped None" per the docs still offers an unsigned, unencrypted channel. UserName tokens are still encrypted with the server certificate on that endpoint, but the session's data and writes are not protected.
  • An attacker on the path can downgrade a client that picks the lowest endpoint.
  • AllowAnonymous=false still holds, so a valid key is still required.
  • Check whether any QA/DEV appsettings rely on this hardening.

Fix direction:

  • Make the default empty and apply the three-profile default only when the section is absent. Or bind with BinderOptions / replace semantics.
  • Add a binding test that pins "configured list replaces the default".
  • Check the other list-typed options with non-empty defaults for the same trap.
Found by the #800 Windows service gate on windev, 2026-11-12. The bug is in both the SDK 1.5 baseline `efb9197a` and SDK 2 master `438484fd`, so it is not an SDK 2 regression. **Symptom:** the node was configured with only the secure profiles: ``` OpcUa__EnabledSecurityProfiles__0=Basic256Sha256SignAndEncrypt OpcUa__EnabledSecurityProfiles__1=Basic256Sha256Sign ``` The `OtOpcUaHost` service still advertises a **`None`/`None` endpoint**, with a UserName token policy. GetEndpoints from a probe on the box returned `None`, `Sign` and `SignAndEncrypt`. `docs/security.md` §hardening says to "drop `None` from `EnabledSecurityProfiles`" and shows this exact JSON list, but that has no effect. **Cause:** `OpcUaApplicationHostOptions.EnabledSecurityProfiles` is initialised to the default list `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]`. `ConfigurationBinder` appends the configured entries to an existing list instead of replacing it, so the configured profiles are added after the defaults and `None` is never removed. The same happens with the JSON array form in appsettings, because the binder treats both forms the same way. **Repro** (no server needed; console project referencing the Host project, at master `438484fd`): ```csharp var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign", ["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt", }).Build(); var options = new OpcUaApplicationHostOptions(); config.GetSection("OpcUa").Bind(options); Console.WriteLine(string.Join(", ", options.EnabledSecurityProfiles)); // None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt ``` The repro is in `scripts/validation/opcua-windows-service/BindRepro/`. The wire evidence is in #800's results (`endpoints-advertised` in every `checks-*.json`). **Impact:** - Every deployment that "dropped None" per the docs still offers an unsigned, unencrypted channel. UserName tokens are still encrypted with the server certificate on that endpoint, but the session's data and writes are not protected. - An attacker on the path can downgrade a client that picks the lowest endpoint. - `AllowAnonymous=false` still holds, so a valid key is still required. - Check whether any QA/DEV appsettings rely on this hardening. **Fix direction:** - Make the default empty and apply the three-profile default only when the section is absent. Or bind with `BinderOptions` / replace semantics. - Add a binding test that pins "configured list replaces the default". - Check the other list-typed options with non-empty defaults for the same trap.
dohertj2 added the type:bugpriority:p1 labels 2026-11-12 10:04:58 -05:00
Author
Owner

Fixed on master a83bf1fa.

Fix. OpcUaApplicationHostOptions.EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles() applies the same [None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt] default only when nothing is configured, so a configured list replaces it. The host consumes the resolved list. Defaults are unchanged.

Empty list. An absent key or [] (binds to nothing) means "not configured" and gives the default. A list that is set but empty fails startup validation (OpcUa:EnabledSecurityProfiles must contain at least 1 item(s)), and BuildSecurityPolicies now throws on empty input instead of silently falling back to a None endpoint. An unknown profile name fails the bind. Docs (security.md, Configuration.md, OpcUaServer.md, CLAUDE.md) updated; the BindRepro now prints only the secure profiles.

Anti-pattern sweep. Searched every { get; set; }/init collection property in src/ for a non-empty initializer. EnabledSecurityProfiles was the only one on a bound options class. The rest are empty (PeerApplicationUris, AkkaClusterOptions.SeedNodes/Roles, LdapOptions.GroupToRole, MeshTransportOptions.ClusterPsks, Calculation.RawTags, Sql.Queries) or are not config-bound (ResilienceFormModel.Policies, deployment artifacts). The empty array defaults would still append rather than replace, but that is harmless while the default is empty.

Tests. OpcUaSecurityProfilesBindingTests (8, through the real AddValidatedOptions wiring: indexed, JSON, env forms, explicit None, default, empty [], set-but-empty, unknown name) and SecurityProfilesReplaceWireTests (2, booted server: secure-only config advertises Sign + SignAndEncrypt and no None; a None session is refused with BadSecurityPolicyRejected; the SignAndEncrypt session works; the default-config case is the control and does admit None). The old "empty input falls back to None" unit test now asserts the throw. Mutation (re-adding the pre-filled default) fails the wire test and 5 of the 8 binding tests. Existing: OpcUaServer.Tests 1265 pass, validator + binding 29 pass, wire subset (trusted channel, dual endpoint, lockout, namespace) 25 pass, slnx builds 0 warnings.

Consumers. No checked-in appsettings, deploy record, or the vd03 upgrade record's deployed-appsettings diff sets EnabledSecurityProfiles, and no QA record does, so none of DEV vd03 or QA vq03/vq04 is known to rely on the old behaviour. The service environments were not inspected (no ad-hoc WinRM): check OpcUa__EnabledSecurityProfiles__* before redeploying a box. Only a box that already lists the setting changes behaviour, and for it None now really goes.

ScadaBridge. It connects with None + API key and has no client certificate (ScadaBridge#121), so on any node where None is dropped its link is refused (BadSecurityPolicyRejected). Do not drop None on a node ScadaBridge uses until #121 is done. vd03 ScadaBridge has no OPC UA link to OtOpcUa. This fix only makes the setting work; it changes no default.

Not deployed. The scadaproj sister-project-history/otopcua.md record is updated.

Fixed on master `a83bf1fa`. **Fix.** `OpcUaApplicationHostOptions.EnabledSecurityProfiles` is now `null` by default; `ResolveEnabledSecurityProfiles()` applies the same `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` default only when nothing is configured, so a configured list replaces it. The host consumes the resolved list. **Defaults are unchanged.** **Empty list.** An absent key or `[]` (binds to nothing) means "not configured" and gives the default. A list that is set but empty fails startup validation (`OpcUa:EnabledSecurityProfiles must contain at least 1 item(s)`), and `BuildSecurityPolicies` now throws on empty input instead of silently falling back to a `None` endpoint. An unknown profile name fails the bind. Docs (`security.md`, `Configuration.md`, `OpcUaServer.md`, CLAUDE.md) updated; the BindRepro now prints only the secure profiles. **Anti-pattern sweep.** Searched every `{ get; set; }`/`init` collection property in `src/` for a non-empty initializer. `EnabledSecurityProfiles` was the only one on a bound options class. The rest are empty (`PeerApplicationUris`, `AkkaClusterOptions.SeedNodes/Roles`, `LdapOptions.GroupToRole`, `MeshTransportOptions.ClusterPsks`, `Calculation.RawTags`, `Sql.Queries`) or are not config-bound (`ResilienceFormModel.Policies`, deployment artifacts). The empty array defaults would still append rather than replace, but that is harmless while the default is empty. **Tests.** `OpcUaSecurityProfilesBindingTests` (8, through the real `AddValidatedOptions` wiring: indexed, JSON, env forms, explicit `None`, default, empty `[]`, set-but-empty, unknown name) and `SecurityProfilesReplaceWireTests` (2, booted server: secure-only config advertises Sign + SignAndEncrypt and no `None`; a `None` session is refused with `BadSecurityPolicyRejected`; the SignAndEncrypt session works; the default-config case is the control and does admit `None`). The old "empty input falls back to None" unit test now asserts the throw. Mutation (re-adding the pre-filled default) fails the wire test and 5 of the 8 binding tests. Existing: OpcUaServer.Tests 1265 pass, validator + binding 29 pass, wire subset (trusted channel, dual endpoint, lockout, namespace) 25 pass, slnx builds 0 warnings. **Consumers.** No checked-in appsettings, deploy record, or the vd03 upgrade record's deployed-appsettings diff sets `EnabledSecurityProfiles`, and no QA record does, so none of DEV vd03 or QA vq03/vq04 is known to rely on the old behaviour. The service environments were not inspected (no ad-hoc WinRM): check `OpcUa__EnabledSecurityProfiles__*` before redeploying a box. Only a box that already lists the setting changes behaviour, and for it `None` now really goes. **ScadaBridge.** It connects with `None` + API key and has no client certificate (ScadaBridge#121), so on any node where `None` is dropped its link is refused (`BadSecurityPolicyRejected`). Do not drop `None` on a node ScadaBridge uses until #121 is done. vd03 ScadaBridge has no OPC UA link to OtOpcUa. This fix only makes the setting work; it changes no default. Not deployed. The scadaproj `sister-project-history/otopcua.md` record is updated.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#879