Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCredentialInstanceAndPathTests.cs
T
Joseph Doherty 63331b0416 fix(security): per-instance credential references (#835) and confined OpcUaClient user-certificate path (#836)
#835: inside one driver namespace, a secret:<ns>/<X>/... reference whose first
segment names ANOTHER existing same-type driver instance (id or name,
case-insensitive; another-instance match beats an own-name match, so renaming
one's driver after a victim's unlocks nothing) is refused at save
(DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance,
Heidenhain included), Test Connect (TestDriverConnect.DriverInstanceId, ConfigDb
read off the mailbox) and live browse (BrowserSessionService, fail-closed without
a ConfigDb). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems +
DriverInstanceIdentities. Shared names and the driver's own id stay valid; env:
is not instance-scoped. Running drivers are unchanged (warn-only window: the
deploy gate and the upgrade preflight / /raw banner list them).

#836: OpcUaClient UserCertificatePath is a relative path inside
Drivers:OpcUaClient:UserCertificateDirectory (default
<LocalAppData>/OtOpcUa/pki/user-certificates). Syntactic rule (CredentialFilePaths,
a DriverCredentialPolicy path field) at every #815 seam; at load time
ResolveSecretRefsAsync joins, canonicalises and requires lexical AND
link-resolved containment before the PFX password is resolved, and
LoadCertificate refuses an unresolved path. A running driver's pre-#836 absolute
path keeps loading with a rate-limited warning (LegacyCredentialReferenceTracker.ReportPath).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 03:52:48 -05:00

92 lines
4.4 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The deploy gate for lmxopcua#835 (a credential reference naming ANOTHER driver instance's secret) and #836 (the
/// OpcUaClient user-certificate path confined to the node's certificate directory).
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorCredentialInstanceAndPathTests
{
private static IReadOnlyList<ValidationError> Validate(params DriverInstance[] drivers) =>
Validate(drivers, []);
private static IReadOnlyList<ValidationError> Validate(DriverInstance[] drivers, Device[] devices) =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = drivers,
Devices = devices,
Tags = [],
}).Where(e => e.Code is CredentialReferenceDeployChecks.Code or CredentialReferenceDeployChecks.OtherInstanceCode).ToList();
private static DriverInstance Drv(string id, string name, string type, string config) =>
new() { DriverInstanceId = id, ClusterId = "c", Name = name, DriverType = type, DriverConfig = config };
[Fact]
public void A_galaxy_driver_naming_another_instances_key_is_refused()
{
var e = Validate(
Drv("DRV-victim", "Victim", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}"""),
Drv("DRV-evil", "Evil", "GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}"""))
.ShouldHaveSingleItem();
e.Code.ShouldBe(CredentialReferenceDeployChecks.OtherInstanceCode);
e.Context.ShouldBe("DRV-evil");
e.Message.ShouldContain("lmxopcua#835");
}
[Fact]
public void A_device_config_naming_another_instance_by_name_is_refused() =>
Validate(
[
Drv("DRV-a", "Victim", "OpcUaClient", "{}"),
Drv("DRV-b", "Evil", "OpcUaClient", "{}"),
],
[new Device { DeviceId = "dev-b", DriverInstanceId = "DRV-b", Name = "d", DeviceConfig = """{"Password":"secret:opcuaclient/victim/pw"}""" }])
.ShouldHaveSingleItem().Context.ShouldBe("dev-b");
[Fact]
public void Shared_and_own_keys_pass() =>
Validate(
Drv("DRV-a", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-a/apikey"}}"""),
Drv("DRV-b", "B", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/apikey"}}"""),
Drv("DRV-c", "C", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/apikey"}}"""))
.ShouldBeEmpty();
[Fact]
public void Heidenhain_cross_instance_is_reported_by_this_gate() =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
DriverInstances =
[
Drv("H1", "line1", "Heidenhain", """{"Access":{"InspectPassword":"secret:heidenhain/line1/inspect"}}"""),
Drv("H2", "line2", "Heidenhain", """{"Access":{"InspectPassword":"secret:heidenhain/line1/inspect"}}"""),
],
Devices = [], Tags = [],
})
.Where(e => e.Code == CredentialReferenceDeployChecks.OtherInstanceCode)
.ShouldHaveSingleItem().Context.ShouldBe("H2");
[Theory]
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"C:\\Windows\\System32\\config\\systemprofile\\host.pfx"}""")]
[InlineData("""{"UserCertificatePath":"/etc/ssl/private/host.pfx"}""")]
[InlineData("""{"UserCertificatePath":"plant1/../../host.pfx"}""")]
public void An_opcuaclient_user_certificate_path_outside_the_directory_is_refused(string config)
{
var e = Validate(Drv("DRV-o", "o", "OpcUaClient", config)).ShouldHaveSingleItem();
e.Code.ShouldBe(CredentialReferenceDeployChecks.Code);
e.Message.ShouldContain("lmxopcua#836");
e.Message.ShouldNotContain("host.pfx");
}
[Fact]
public void A_relative_user_certificate_path_passes() =>
Validate(Drv("DRV-o", "o", "OpcUaClient", """{"AuthType":"Certificate","UserCertificatePath":"plant1/user.pfx"}""")).ShouldBeEmpty();
}