Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialInstanceAndPathSavePathTests.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

131 lines
6.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// lmxopcua#835 (a credential reference naming ANOTHER driver instance's secret) and #836 (the OpcUaClient
/// user-certificate path confined to a node directory) at the SAVE path (RawTreeService): nothing reaches the
/// config DB, through driver and device create + update.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialInstanceAndPathSavePathTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static string Galaxy(string reference) =>
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference);
private static async Task<(RawTreeService Svc, string Db, string VictimId)> WithVictimAsync()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var victim = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Victim", DriverTypeNames.Galaxy,
Galaxy("secret:galaxy/victim-own/apikey"), Ct);
victim.Ok.ShouldBeTrue(victim.Error);
return (svc, name, victim.CreatedId!);
}
[Fact]
public async Task CreateDriver_refuses_a_reference_naming_another_instance()
{
var (svc, name, victimId) = await WithVictimAsync();
var byName = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil1", DriverTypeNames.Galaxy,
Galaxy("secret:galaxy/Victim/apikey"), Ct);
var byId = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil2", DriverTypeNames.Galaxy,
Galaxy($"secret:galaxy/{victimId}/apikey"), Ct);
byName.Ok.ShouldBeFalse();
byName.Error!.ShouldContain("lmxopcua#835");
byId.Ok.ShouldBeFalse();
using var db = RawTreeTestDb.CreateNamed(name);
db.DriverInstances.ShouldNotContain(d => d.Name == "evil1" || d.Name == "evil2");
}
[Fact]
public async Task Update_and_device_paths_refuse_and_keep_the_stored_config()
{
var (svc, name, victimId) = await WithVictimAsync();
var evil = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil", DriverTypeNames.Galaxy,
Galaxy("secret:galaxy/shared/apikey"), Ct);
evil.Ok.ShouldBeTrue(evil.Error);
byte[] rv;
string deviceId;
byte[] deviceRv;
using (var db = RawTreeTestDb.CreateNamed(name))
{
rv = db.DriverInstances.Single(d => d.DriverInstanceId == evil.CreatedId).RowVersion;
var device = db.Devices.Single(d => d.DriverInstanceId == evil.CreatedId);
deviceId = device.DeviceId;
deviceRv = device.RowVersion;
}
var stolen = Galaxy($"secret:galaxy/{victimId}/apikey");
(await svc.UpdateDriverAsync(evil.CreatedId!, "evil", stolen, null, rv, Ct)).Error.ShouldNotBeNull().ShouldContain("lmxopcua#835");
(await svc.UpdateDeviceAsync(deviceId, "dev", stolen, true, deviceRv, Ct)).Ok.ShouldBeFalse();
(await svc.CreateDeviceAsync(evil.CreatedId!, "dev2", stolen, Ct)).Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(name);
check.DriverInstances.Single(d => d.DriverInstanceId == evil.CreatedId).DriverConfig.ShouldContain("secret:galaxy/shared/apikey");
check.Devices.Where(d => d.DriverInstanceId == evil.CreatedId).ShouldAllBe(d => !d.DeviceConfig.Contains(victimId));
}
[Fact]
public async Task A_driver_may_save_its_own_id_scoped_secret_on_update_and_on_its_devices()
{
var (svc, name, victimId) = await WithVictimAsync();
byte[] rv;
string deviceId;
byte[] deviceRv;
using (var db = RawTreeTestDb.CreateNamed(name))
{
rv = db.DriverInstances.Single(d => d.DriverInstanceId == victimId).RowVersion;
var device = db.Devices.Single(d => d.DriverInstanceId == victimId);
deviceId = device.DeviceId;
deviceRv = device.RowVersion;
}
var own = Galaxy($"secret:galaxy/{victimId}/apikey");
(await svc.UpdateDriverAsync(victimId, "Victim", own, null, rv, Ct)).Ok.ShouldBeTrue();
(await svc.UpdateDeviceAsync(deviceId, "dev", Galaxy("secret:galaxy/Victim/apikey"), true, deviceRv, Ct)).Ok.ShouldBeTrue();
}
[Theory]
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"C:\\Windows\\System32\\config\\systemprofile\\host.pfx"}""")]
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"/etc/ssl/private/host.pfx"}""")]
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"../host.pfx"}""")]
public async Task An_opcuaclient_user_certificate_path_outside_the_directory_is_never_stored(string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ouc836", DriverTypeNames.OpcUaClient, config, Ct);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("lmxopcua#836");
DriverConfigSaveGate.Validate(DriverTypeNames.OpcUaClient, config).ShouldNotBeNull(); // the modals' live check too
using var db = RawTreeTestDb.CreateNamed(name);
db.DriverInstances.ShouldNotContain(d => d.Name == "ouc836");
}
[Fact]
public async Task A_relative_user_certificate_path_saves()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ouc-ok", DriverTypeNames.OpcUaClient,
"""{"AuthType":"Certificate","UserCertificatePath":"plant1/user.pfx"}""", Ct)).Ok.ShouldBeTrue();
}
[Fact]
public async Task LoadDeviceDriverInstanceId_names_the_owning_driver()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
(await svc.LoadDeviceDriverInstanceIdAsync(RawTreeTestDb.FolderedDeviceId, Ct)).ShouldBe(RawTreeTestDb.FolderedDriverId);
(await svc.LoadDeviceDriverInstanceIdAsync("DEV-nope", Ct)).ShouldBeNull();
}
}