Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialInstanceScopeTests.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

117 lines
6.1 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// lmxopcua#835: inside one namespace, a credential reference whose instance segment
/// (<c>secret:galaxy/&lt;X&gt;/…</c>) names ANOTHER existing instance of the same driver type is refused.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialInstanceScopeTests
{
private static readonly DriverInstanceIdentity[] Instances =
[
new("DRV-aaa", "PlantA", DriverTypeNames.Galaxy),
new("DRV-bbb", "PlantB", DriverTypeNames.Galaxy),
new("DRV-ccc", "UpstreamC", DriverTypeNames.OpcUaClient),
new("DRV-ddd", "UpstreamD", DriverTypeNames.OpcUaClient),
];
private static string Galaxy(string apiKeyRef) =>
"{\"Gateway\":{\"Endpoint\":\"http://evil:5001\",\"ApiKeySecretRef\":" + System.Text.Json.JsonSerializer.Serialize(apiKeyRef) + "}}";
[Theory]
[InlineData("secret:galaxy/DRV-bbb/apikey")]
[InlineData("secret:galaxy/PlantB/apikey")]
[InlineData("secret:galaxy/plantb/apikey")] // case-insensitive
[InlineData("secret:otopcua/galaxy/PlantB/apikey")] // the second namespace
[InlineData("${secret:galaxy/PlantB/apikey}")] // the token spelling
[InlineData("secret:galaxy/PlantB")] // the segment alone
public void A_reference_naming_another_instance_is_refused(string reference)
{
var problem = DriverCredentialPolicy
.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
.ShouldHaveSingleItem();
problem.ShouldContain("Gateway.ApiKeySecretRef");
problem.ShouldContain("lmxopcua#835");
problem.ShouldContain("DRV-bbb");
}
[Theory]
[InlineData("secret:galaxy/DRV-aaa/apikey")] // its own id
[InlineData("secret:galaxy/PlantA/apikey")] // its own name
[InlineData("secret:galaxy/gw/apikey")] // a shared name no instance uses
[InlineData("secret:galaxy/apikey")]
[InlineData("secret:galaxy/UpstreamC/apikey")] // another TYPE's instance — not this namespace's owner
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // env carries no instance convention
[InlineData("dev:k")]
[InlineData("")]
public void Own_shared_and_non_secret_references_pass(string reference) =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
.ShouldBeEmpty();
[Fact]
public void A_driver_being_created_may_name_no_existing_instance() =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/PlantA/apikey"), null, Instances)
.ShouldHaveSingleItem();
[Fact]
public void Renaming_ones_own_driver_after_the_victim_does_not_unlock_the_victims_secret()
{
// Attacker DRV-zzz renamed itself "PlantB" (names are unique per folder, not fleet-wide).
var withTwin = Instances.Append(new DriverInstanceIdentity("DRV-zzz", "PlantB", DriverTypeNames.Galaxy));
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/PlantB/apikey"), "DRV-zzz", withTwin)
.ShouldHaveSingleItem();
}
[Fact]
public void The_ack_password_and_device_level_fields_are_checked_too()
{
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/DRV-bbb/ack"}}""", "DRV-aaa", Instances)
.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef");
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Galaxy, """{"Devices":[{"Gateway":{"ApiKeySecretRef":"secret:galaxy/PlantB/k"}}]}""", "DRV-aaa", Instances)
.ShouldHaveSingleItem().ShouldContain("Devices[0].Gateway.ApiKeySecretRef");
}
[Theory]
[InlineData("""{"Password":"secret:opcuaclient/UpstreamD/pw"}""", 1)]
[InlineData("""{"UserCertificatePassword":"secret:opcuaclient/DRV-ddd/pfx"}""", 1)]
[InlineData("""{"Password":"secret:opcuaclient/UpstreamC/pw"}""", 0)]
[InlineData("""{"Password":"secret:opcuaclient/shared/pw"}""", 0)]
public void OpcUaClient_has_the_same_rule(string config, int expected) =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.OpcUaClient, config, "DRV-ccc", Instances)
.Count.ShouldBe(expected);
[Fact]
public void Heidenhain_has_the_same_rule()
{
IEnumerable<DriverInstanceIdentity> hh = [new("H1", "line1", DriverTypeNames.Heidenhain), new("H2", "line2", DriverTypeNames.Heidenhain)];
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:heidenhain/line2/inspect"}}""", "H1", hh)
.ShouldHaveSingleItem();
}
[Fact]
public void HasInstanceSegments_gates_the_ConfigDb_read()
{
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/x/apikey")).ShouldBeTrue();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeFalse();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:ConnectionStrings__ConfigDb")).ShouldBeFalse();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Modbus, """{"Password":"secret:galaxy/x"}""").ShouldBeFalse();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, "not json").ShouldBeFalse();
}
[Fact]
public void The_combined_overload_reports_both_rules()
{
var config = """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/PlantB/k"},"AlarmAck":{"FallbackPasswordSecretRef":"env:ConnectionStrings__ConfigDb"}}""";
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, config, "DRV-aaa", Instances);
problems.Count.ShouldBe(2);
problems.ShouldContain(p => p.Contains("lmxopcua#815"));
problems.ShouldContain(p => p.Contains("lmxopcua#835"));
}
}