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>
168 lines
7.5 KiB
C#
168 lines
7.5 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>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");
|
|
}
|
|
}
|
|
}
|