271b96a644
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges the stored DriverConfig with the typed device config). The server (DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and BrowserSessionService) honours the named owner only when every endpoint value (new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver stores; otherwise the owner is null and its instance-named secrets are refused. Every Test Connect / browse whose config carries an in-scope secret:/env: reference is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a structured Information line: user, driver, endpoint hosts, outcome). L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets AuthType to non-Certificate (string or ordinal); the deploy gate also checks the runtime-merged driver+device config so the switch cannot be split from the path; the form no longer persists the hidden field. L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open failures into its error message. L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory. Co-Authored-By: Claude <noreply@anthropic.com>
213 lines
9.1 KiB
C#
213 lines
9.1 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#835 security review: I-1 (an UNSAVED endpoint may not borrow the named owner's own secrets; every
|
|
/// credential-carrying Test Connect / browse is audited) and L-2 (a ConfigDb fault in the browse check is a refusal,
|
|
/// not an exception).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class CredentialUseReviewTests
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private const string StoredEndpoint = "http://gw-victim:5001";
|
|
|
|
private static string Galaxy(string endpoint, string reference) =>
|
|
"""{"Gateway":{"Endpoint":"EP","ApiKeySecretRef":"REF"}}""".Replace("EP", endpoint).Replace("REF", reference);
|
|
|
|
/// <summary>The victim's driver stores no endpoint; its device stores <see cref="StoredEndpoint"/> (v3 layout).</summary>
|
|
private static IDbContextFactory<OtOpcUaConfigDbContext> VictimFactory()
|
|
{
|
|
var name = $"i1-{Guid.NewGuid():N}";
|
|
using (var db = RawTreeTestDb.CreateNamed(name))
|
|
{
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = "DRV-victim", ClusterId = "c", Name = "Victim", DriverType = DriverTypeNames.Galaxy,
|
|
DriverConfig = """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""",
|
|
});
|
|
db.Devices.Add(new Device
|
|
{
|
|
DeviceId = "DEV-victim", DriverInstanceId = "DRV-victim", Name = "gw",
|
|
DeviceConfig = Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"),
|
|
});
|
|
db.SaveChanges();
|
|
}
|
|
|
|
return RawTreeTestDb.Factory(name);
|
|
}
|
|
|
|
private static BrowserSessionService Service(
|
|
RecordingBrowser browser, IDbContextFactory<OtOpcUaConfigDbContext>? factory, IAuditWriter? audit = null) =>
|
|
new([browser], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new NoUniversal(), null!,
|
|
new FixedUser("designer1"), factory, audit);
|
|
|
|
[Fact]
|
|
public async Task Browse_from_the_owners_device_with_an_unsaved_endpoint_cannot_use_the_owners_secret()
|
|
{
|
|
var browser = new RecordingBrowser();
|
|
var result = await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
|
|
Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#835");
|
|
result.Message.ShouldContain("save the change first");
|
|
browser.Opens.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Browse_with_the_saved_endpoint_may_use_the_owners_secret()
|
|
{
|
|
var browser = new RecordingBrowser();
|
|
await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
|
|
Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
|
|
browser.Opens.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Browse_naming_an_owner_of_another_type_gets_no_owner()
|
|
{
|
|
var browser = new RecordingBrowser();
|
|
var result = await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
|
|
Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"), "DRV-does-not-exist", Ct);
|
|
result.Ok.ShouldBeFalse();
|
|
browser.Opens.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_config_db_fault_in_the_browse_check_is_a_refusal_not_an_exception()
|
|
{
|
|
var browser = new RecordingBrowser();
|
|
var result = await Service(browser, new ThrowingFactory()).OpenAsync(DriverTypeNames.Galaxy,
|
|
Galaxy(StoredEndpoint, "secret:galaxy/shared/apikey"), "DRV-victim", Ct);
|
|
|
|
result.Ok.ShouldBeFalse();
|
|
result.Message.ShouldNotBeNull().ShouldContain("could not be read");
|
|
browser.Opens.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_credential_carrying_browse_is_audited_with_user_driver_host_and_outcome()
|
|
{
|
|
var audit = new RecordingAudit();
|
|
var browser = new RecordingBrowser();
|
|
var svc = Service(browser, VictimFactory(), audit);
|
|
|
|
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
|
|
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy(StoredEndpoint, "env:OTOPCUA_GALAXY_KEY"), "DRV-victim", Ct);
|
|
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy(StoredEndpoint, "dev:literal"), "DRV-victim", Ct); // no reference ⇒ no row
|
|
|
|
audit.Events.Count.ShouldBe(2);
|
|
var refused = audit.Events[0];
|
|
refused.Category.ShouldBe(CredentialUseAudit.Category);
|
|
refused.Action.ShouldBe(CredentialUseAudit.BrowseAction);
|
|
refused.Actor.ShouldBe("designer1");
|
|
refused.Target.ShouldBe("DRV-victim");
|
|
refused.Outcome.ShouldBe(AuditOutcome.Failure);
|
|
refused.DetailsJson.ShouldNotBeNull().ShouldContain("attacker:5001");
|
|
refused.DetailsJson.ShouldNotContain("DRV-victim/apikey"); // the reference itself is never recorded
|
|
audit.Events[1].DetailsJson.ShouldNotBeNull().ShouldContain("gw-victim:5001");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Every_credential_carrying_test_connect_is_audited()
|
|
{
|
|
var audit = new RecordingAudit();
|
|
var probe = new AdminProbeService(
|
|
new FixedReplyClient(new TestDriverConnectResult(false, "Test Connect refused: x", null, Guid.Empty)),
|
|
audit, new FixedUser("designer2"), NullLogger<AdminProbeService>.Instance);
|
|
|
|
await probe.TestAsync(DriverTypeNames.Galaxy, Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), 5, Ct, "DRV-victim");
|
|
await probe.TestAsync(DriverTypeNames.Modbus, """{"Host":"plc"}""", 5, Ct);
|
|
|
|
var e = audit.Events.ShouldHaveSingleItem();
|
|
e.Action.ShouldBe(CredentialUseAudit.TestConnectAction);
|
|
e.Actor.ShouldBe("designer2");
|
|
e.Target.ShouldBe("DRV-victim");
|
|
e.Outcome.ShouldBe(AuditOutcome.Failure);
|
|
e.DetailsJson.ShouldNotBeNull().ShouldContain("attacker:5001");
|
|
}
|
|
|
|
[Fact]
|
|
public void Hosts_drop_uri_user_info()
|
|
{
|
|
CredentialUseAudit.Hosts(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://user:pw@plc1:4840/x"}""")
|
|
.ShouldHaveSingleItem().ShouldBe("plc1:4840");
|
|
}
|
|
|
|
private sealed class RecordingBrowser : IDriverBrowser
|
|
{
|
|
public int Opens { get; private set; }
|
|
|
|
public string DriverType => DriverTypeNames.Galaxy;
|
|
|
|
public Task<IBrowseSession> OpenAsync(string configJson, CancellationToken cancellationToken)
|
|
{
|
|
Opens++;
|
|
throw new InvalidOperationException("opened");
|
|
}
|
|
}
|
|
|
|
private sealed class NoUniversal : IUniversalDriverBrowser
|
|
{
|
|
public bool CanBrowse(string driverType, string configJson) => false;
|
|
|
|
public Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct) =>
|
|
throw new InvalidOperationException("opened");
|
|
}
|
|
|
|
private sealed class FixedUser(string name) : AuthenticationStateProvider
|
|
{
|
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
|
|
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, name)], "test"))));
|
|
}
|
|
|
|
private sealed class RecordingAudit : IAuditWriter
|
|
{
|
|
public List<AuditEvent> Events { get; } = [];
|
|
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
|
{
|
|
Events.Add(evt);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class ThrowingFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
public OtOpcUaConfigDbContext CreateDbContext() => throw new InvalidOperationException("ConfigDb unreachable");
|
|
}
|
|
|
|
private sealed class FixedReplyClient(TestDriverConnectResult reply) : IAdminOperationsClient
|
|
{
|
|
public Task<StartDeploymentResult> StartDeploymentAsync(string createdBy, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task<AcknowledgeAlarmResult> AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<ShelveAlarmResult> ShelveAlarmAsync(
|
|
string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, string? comment, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<T> AskAsync<T>(object message, CancellationToken ct) => Task.FromResult((T)(object)reply);
|
|
}
|
|
}
|