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

203 lines
9.1 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>
/// lmxopcua#815: the AdminUI live browse (Galaxy / OpcUaClient bespoke browsers, and the Discover-backed universal
/// browser) resolves the config's credential references and sends them to the author-chosen endpoint — an
/// out-of-namespace reference is refused before any browser is opened.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BrowserSessionCredentialScopeTests
{
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:galaxy/main/apikey"}}""")]
public async Task Out_of_namespace_reference_never_opens_a_browser(string driverType, string config)
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var bespoke2 = new RecordingBrowser(DriverTypeNames.OpcUaClient);
var universal = new RecordingUniversal();
var svc = new BrowserSessionService(
[bespoke, bespoke2], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, universal, null!, null!);
var result = await svc.OpenAsync(driverType, config, TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#815");
bespoke.Opens.ShouldBe(0);
bespoke2.Opens.ShouldBe(0);
universal.Opens.ShouldBe(0);
}
/// <summary>Positive control: a namespaced reference reaches the browser.</summary>
[Fact]
public async Task A_namespaced_reference_reaches_the_browser()
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
SeededFactory());
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""",
TestContext.Current.CancellationToken);
bespoke.Opens.ShouldBe(1);
}
/// <summary>lmxopcua#835: a reference naming ANOTHER instance's secret never opens a browser.</summary>
[Theory]
[InlineData("DRV-evil")]
[InlineData(null)]
public async Task A_reference_naming_another_instance_never_opens_a_browser(string? ownerId)
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var universal = new RecordingUniversal();
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, universal, null!, null!, SeededFactory());
var result = await svc.OpenAsync(DriverTypeNames.Galaxy,
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""", ownerId,
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#835");
bespoke.Opens.ShouldBe(0);
universal.Opens.ShouldBe(0);
}
/// <summary>lmxopcua#849: an env: reference named after ANOTHER instance never opens a browser either.</summary>
[Theory]
[InlineData("env:OTOPCUA_GALAXY_VICTIM_KEY")]
[InlineData("env:GALAXY_MXGW_DRV_VICTIM_KEY")]
public async Task An_env_reference_naming_another_instance_never_opens_a_browser(string reference)
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
SeededFactory());
var result = await svc.OpenAsync(DriverTypeNames.Galaxy,
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference), "DRV-evil",
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#849");
bespoke.Opens.ShouldBe(0);
}
/// <summary>Positive control: a shared env variable that names no instance reaches the browser.</summary>
[Fact]
public async Task A_shared_env_variable_reaches_the_browser()
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
SeededFactory());
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"}}""",
TestContext.Current.CancellationToken);
bespoke.Opens.ShouldBe(1);
}
/// <summary>Positive control: the owning driver's own id-scoped secret reaches the browser.</summary>
[Fact]
public async Task The_owners_own_secret_reaches_the_browser()
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!,
SeededFactory());
await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/Victim/apikey"}}""", "DRV-victim",
TestContext.Current.CancellationToken);
bespoke.Opens.ShouldBe(1);
}
/// <summary>Without the instance list the cross-instance rule cannot be checked, so the browse fails closed.</summary>
[Fact]
public async Task Without_a_config_db_an_in_scope_secret_reference_fails_closed()
{
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
var result = await svc.OpenAsync(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/main/apikey"}}""",
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("fail-closed");
bespoke.Opens.ShouldBe(0);
}
/// <summary>lmxopcua#836: an absolute OpcUaClient user-certificate path never opens a browser.</summary>
[Fact]
public async Task An_absolute_user_certificate_path_never_opens_a_browser()
{
var bespoke = new RecordingBrowser(DriverTypeNames.OpcUaClient);
var svc = new BrowserSessionService(
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
var result = await svc.OpenAsync(DriverTypeNames.OpcUaClient,
"""{"EndpointUrl":"opc.tcp://evil:4840","AuthType":"Certificate","UserCertificatePath":"C:\\ProgramData\\x\\host.pfx"}""",
TestContext.Current.CancellationToken);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#836");
bespoke.Opens.ShouldBe(0);
}
private static Microsoft.EntityFrameworkCore.IDbContextFactory<ZB.MOM.WW.OtOpcUa.Configuration.OtOpcUaConfigDbContext> SeededFactory()
{
var name = $"browse-835-{Guid.NewGuid():N}";
using (var db = Uns.RawTreeTestDb.CreateNamed(name))
{
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();
}
return Uns.RawTreeTestDb.Factory(name);
}
private sealed class RecordingBrowser(string driverType) : IDriverBrowser
{
public int Opens { get; private set; }
public string DriverType => driverType;
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
{
Opens++;
throw new InvalidOperationException("opened");
}
}
private sealed class RecordingUniversal : IUniversalDriverBrowser
{
public int Opens { get; private set; }
public bool CanBrowse(string driverType, string configJson) => true;
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
Opens++;
throw new InvalidOperationException("opened");
}
}
}