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>
This commit is contained in:
@@ -9,11 +9,17 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
/// <param name="ConfigJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
|
||||
/// <param name="TimeoutSeconds">Per-probe timeout; server clamps to [1, 60].</param>
|
||||
/// <param name="CorrelationId">Round-trip correlation token.</param>
|
||||
/// <param name="DriverInstanceId">
|
||||
/// The driver instance the config belongs to (lmxopcua#835 — its own <c>secret:galaxy/<id>/…</c> references are
|
||||
/// allowed, another instance's are refused). Null = an instance not yet created: a reference naming ANY existing
|
||||
/// instance is refused, so omitting it can only make the check stricter.
|
||||
/// </param>
|
||||
public sealed record TestDriverConnect(
|
||||
string DriverType,
|
||||
string ConfigJson,
|
||||
int TimeoutSeconds,
|
||||
Guid CorrelationId);
|
||||
Guid CorrelationId,
|
||||
string? DriverInstanceId = null);
|
||||
|
||||
/// <summary>Reply for <see cref="TestDriverConnect"/>.</summary>
|
||||
/// <param name="Ok">True iff the probe succeeded.</param>
|
||||
|
||||
+32
-2
@@ -30,21 +30,51 @@ public static class CredentialReferenceDeployChecks
|
||||
+ "field at it. A driver deployed before this upgrade keeps resolving the old reference (warn-only, deprecated) "
|
||||
+ "only until this deploy succeeds. See docs/security.md §\"Credential references are namespaced per driver\".";
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#835: a credential reference whose instance segment (<c>secret:galaxy/<X>/…</c>) names ANOTHER
|
||||
/// existing driver instance of the same type, by id or name.
|
||||
/// </summary>
|
||||
public const string OtherInstanceCode = "CredentialReferenceOtherInstance";
|
||||
|
||||
/// <summary>Appended to every <see cref="OtherInstanceCode"/> error.</summary>
|
||||
public const string OtherInstanceHint =
|
||||
" Store this driver's own credential under its own driver id (or a shared name that is no other driver's id or "
|
||||
+ "name) and point the field at it. A driver deployed before this upgrade keeps resolving the old reference until "
|
||||
+ "this deploy succeeds. See docs/security.md §\"Per-instance credential references\".";
|
||||
|
||||
/// <summary>Runs the gate.</summary>
|
||||
/// <param name="draft">The draft.</param>
|
||||
/// <returns>The errors.</returns>
|
||||
public static IReadOnlyList<ValidationError> Errors(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
var instances = draft.DriverInstances
|
||||
.Select(d => new DriverInstanceIdentity(d.DriverInstanceId, d.Name, d.DriverType))
|
||||
.ToList();
|
||||
foreach (var driver in draft.DriverInstances)
|
||||
{
|
||||
if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
|
||||
var devices = draft.Devices
|
||||
.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
// lmxopcua#835 — applies to Heidenhain too (HeidenhainDeployChecks reports only the namespace rule).
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
driver.DriverType, driver.DriverConfig, driver.DriverInstanceId, instances))
|
||||
errors.Add(new(OtherInstanceCode, $"{driver.DriverType} driver '{driver.Name}': {problem}{OtherInstanceHint}", driver.DriverInstanceId));
|
||||
foreach (var device in devices)
|
||||
{
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeOtherInstanceProblems(
|
||||
driver.DriverType, device.DeviceConfig, driver.DriverInstanceId, instances))
|
||||
errors.Add(new(OtherInstanceCode, $"{driver.DriverType} device '{device.Name}': {problem}{OtherInstanceHint}", device.DeviceId));
|
||||
}
|
||||
|
||||
if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}{MigrationHint}", driver.DriverInstanceId));
|
||||
|
||||
foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
|
||||
foreach (var device in devices)
|
||||
{
|
||||
foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
|
||||
errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}{MigrationHint}", device.DeviceId));
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the driver-instance identities the cross-instance credential rule (lmxopcua#835,
|
||||
/// <see cref="DriverCredentialPolicy.DescribeOtherInstanceProblems"/>) matches against — the ONE query the AdminUI save
|
||||
/// path, live browse and Test Connect share (the deploy gate reads the same rows from its draft snapshot).
|
||||
/// </summary>
|
||||
public static class DriverInstanceIdentities
|
||||
{
|
||||
/// <summary>Every stored driver instance of <paramref name="driverType"/>, fleet-wide.</summary>
|
||||
/// <param name="db">The config DB.</param>
|
||||
/// <param name="driverType">The driver type (compared case-insensitively, client-side).</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The identities.</returns>
|
||||
public static async Task<IReadOnlyList<DriverInstanceIdentity>> LoadAsync(
|
||||
OtOpcUaConfigDbContext db, string driverType, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
var rows = await db.DriverInstances.AsNoTracking()
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
return rows
|
||||
.Where(r => string.Equals(r.DriverType, driverType, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(r => new DriverInstanceIdentity(r.DriverInstanceId, r.Name, r.DriverType))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cross-instance problems of <paramref name="configJson"/> — reading the ConfigDb only when the config carries
|
||||
/// an in-scope <c>secret:</c> reference at all.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB.</param>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="configJson">The config about to be stored / probed / browsed.</param>
|
||||
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The problems.</returns>
|
||||
public static async Task<IReadOnlyList<string>> DescribeOtherInstanceProblemsAsync(
|
||||
OtOpcUaConfigDbContext db, string? driverType, string? configJson, string? ownDriverInstanceId, CancellationToken ct)
|
||||
{
|
||||
if (driverType is null || !DriverCredentialPolicy.HasInstanceSegments(driverType, configJson)) return [];
|
||||
var instances = await LoadAsync(db, driverType, ct).ConfigureAwait(false);
|
||||
return DriverCredentialPolicy.DescribeOtherInstanceProblems(driverType, configJson, ownDriverInstanceId, instances);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// A driver-config field naming a node-local file that holds key material (lmxopcua#836 — OpcUaClient
|
||||
/// <c>UserCertificatePath</c>). The node loads the file and USES its private key against the author-chosen endpoint,
|
||||
/// so an unrestricted path lets a Designer make the node sign a hostile server's nonce with any PFX the service
|
||||
/// account can read. The field is therefore confined to one node-configured directory.
|
||||
/// </summary>
|
||||
/// <param name="Name">The JSON property name (matched case-insensitively, as the driver binds it).</param>
|
||||
/// <param name="DirectoryDescription">How the operator configures the directory (for messages).</param>
|
||||
public sealed record CredentialPathField(string Name, string DirectoryDescription);
|
||||
|
||||
/// <summary>
|
||||
/// The one rule for <see cref="CredentialPathField"/> values (lmxopcua#836): authored as a RELATIVE path inside a
|
||||
/// node-configured directory, checked syntactically at every admin seam (save, deploy gate, Test Connect, browse) and
|
||||
/// canonicalised — including symbolic links / junctions — at load time by <see cref="ResolveWithin"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The admin seams cannot see the driver node's directory, so the authored form carries no directory at all: an
|
||||
/// absolute, rooted, drive-qualified or UNC path is refused there, as is any <c>.</c> / <c>..</c> / empty segment or a
|
||||
/// character outside <c>[A-Za-z0-9._-]</c>. Resolution re-checks the syntax (fail-closed), joins it to the node's
|
||||
/// directory, and requires both the lexical result and the real (link-resolved) result to stay inside the real
|
||||
/// directory.
|
||||
/// </remarks>
|
||||
public static class CredentialFilePaths
|
||||
{
|
||||
private const int MaxLength = 260;
|
||||
|
||||
/// <summary>True when <paramref name="value"/> is blank or an acceptable relative path.</summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <returns>Whether it may be stored / deployed.</returns>
|
||||
public static bool IsAcceptable(string? value) => string.IsNullOrEmpty(value) || IsAcceptableRelative(value);
|
||||
|
||||
/// <summary>True when <paramref name="value"/> is a non-empty relative path of plain segments.</summary>
|
||||
/// <param name="value">The authored value.</param>
|
||||
/// <returns>Whether it is a relative path the resolver will accept.</returns>
|
||||
public static bool IsAcceptableRelative(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length > MaxLength) return false;
|
||||
if (value[0] is '/' or '\\') return false;
|
||||
var segments = value.Split('/', '\\');
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (seg.Length == 0 || seg is "." or "..") return false;
|
||||
if (!seg.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.')) return false;
|
||||
}
|
||||
|
||||
return !Path.IsPathRooted(value);
|
||||
}
|
||||
|
||||
/// <summary>The one wording of the rule — names the field and the directory setting, never the value.</summary>
|
||||
/// <param name="field">The field path.</param>
|
||||
/// <param name="directoryDescription">How the directory is configured.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string RuleMessage(string field, string directoryDescription) =>
|
||||
$"{field} must be blank or a relative path (e.g. 'plant1/user.pfx') inside the node's certificate directory "
|
||||
+ $"({directoryDescription}) — an absolute, drive or UNC path, a '.'/'..' segment, or a character outside "
|
||||
+ "[A-Za-z0-9._-] is refused (lmxopcua#836).";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an authored relative path inside <paramref name="directory"/>, fail-closed: throws
|
||||
/// <see cref="CredentialReferenceException"/> when the value is not an acceptable relative path, or when the
|
||||
/// result — lexically or after resolving every symbolic link / junction on the way — leaves the directory.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored relative path.</param>
|
||||
/// <param name="directory">The node's configured directory.</param>
|
||||
/// <param name="field">Field label for messages.</param>
|
||||
/// <param name="directoryDescription">How the directory is configured (for messages).</param>
|
||||
/// <returns>The full path to load (the lexical path inside the directory).</returns>
|
||||
public static string ResolveWithin(string? value, string directory, string field, string directoryDescription)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
|
||||
if (!IsAcceptableRelative(value))
|
||||
throw new CredentialReferenceException(RuleMessage(field, directoryDescription) + " (fail-closed)");
|
||||
|
||||
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
var full = Path.GetFullPath(Path.Combine(root, value!));
|
||||
if (!IsUnder(full, root))
|
||||
throw new CredentialReferenceException($"{field} resolves outside the certificate directory (fail-closed).");
|
||||
|
||||
var realRoot = RealPath(root);
|
||||
var realFull = RealPath(full);
|
||||
if (!IsUnder(realFull, realRoot))
|
||||
throw new CredentialReferenceException(
|
||||
$"{field} resolves (through a symbolic link or junction) outside the certificate directory (fail-closed).");
|
||||
return full;
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison =>
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
private static bool IsUnder(string path, string root)
|
||||
{
|
||||
var prefix = root.EndsWith(Path.DirectorySeparatorChar) ? root : root + Path.DirectorySeparatorChar;
|
||||
return path.StartsWith(prefix, PathComparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The path with every existing symbolic link / junction component replaced by its final target, walked from the
|
||||
/// root (a link's target may itself sit under another link). Components that do not exist are kept verbatim.
|
||||
/// </summary>
|
||||
private static string RealPath(string fullPath)
|
||||
{
|
||||
var root = Path.GetPathRoot(fullPath) ?? "";
|
||||
var current = root;
|
||||
var rest = fullPath[root.Length..].Split(
|
||||
[Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
|
||||
for (var i = 0; i < rest.Length; i++)
|
||||
{
|
||||
current = Path.Combine(current, rest[i]);
|
||||
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
|
||||
if (!info.Exists || info.LinkTarget is null) continue;
|
||||
var target = info.ResolveLinkTarget(returnFinalTarget: true);
|
||||
if (target is null) continue;
|
||||
// Re-walk the target itself: its own parents may be links too.
|
||||
current = RealPath(Path.GetFullPath(target.FullName));
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,19 @@ public sealed record CredentialField(string Name, CredentialLiteralPolicy Litera
|
||||
/// <param name="DriverType">The <see cref="DriverTypeNames"/> value.</param>
|
||||
/// <param name="Scope">The namespace its references are confined to.</param>
|
||||
/// <param name="Fields">The credential fields its DriverConfig/DeviceConfig may carry.</param>
|
||||
public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields);
|
||||
/// <param name="PathFields">Fields naming a node-local key-material file, confined to a node directory (lmxopcua#836).</param>
|
||||
public sealed record DriverCredentialRules(
|
||||
string DriverType, CredentialScope Scope, IReadOnlyList<CredentialField> Fields, IReadOnlyList<CredentialPathField>? PathFields = null)
|
||||
{
|
||||
/// <summary>The path fields (never null).</summary>
|
||||
public IReadOnlyList<CredentialPathField> PathFieldList => PathFields ?? [];
|
||||
}
|
||||
|
||||
/// <summary>The identity of one driver instance, as the cross-instance rule (lmxopcua#835) matches it.</summary>
|
||||
/// <param name="DriverInstanceId">The instance id.</param>
|
||||
/// <param name="Name">The instance's display name.</param>
|
||||
/// <param name="DriverType">The instance's driver type.</param>
|
||||
public sealed record DriverInstanceIdentity(string DriverInstanceId, string Name, string DriverType);
|
||||
|
||||
/// <summary>
|
||||
/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
|
||||
@@ -39,6 +51,14 @@ public static class DriverCredentialPolicy
|
||||
/// <summary>OPC UA Client user / certificate passwords: <c>secret:opcuaclient/…</c> / <c>env:OTOPCUA_OPCUACLIENT_…</c>.</summary>
|
||||
public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
|
||||
|
||||
/// <summary>The node config key of the OpcUaClient user-certificate directory (lmxopcua#836).</summary>
|
||||
public const string OpcUaClientUserCertificateDirectoryKey = "Drivers:OpcUaClient:UserCertificateDirectory";
|
||||
|
||||
/// <summary>OpcUaClient <c>UserCertificatePath</c>: a relative path inside the node's user-certificate directory (#836).</summary>
|
||||
public static readonly CredentialPathField OpcUaClientUserCertificatePathField = new(
|
||||
"UserCertificatePath",
|
||||
OpcUaClientUserCertificateDirectoryKey + ", default <LocalAppData>/OtOpcUa/pki/user-certificates");
|
||||
|
||||
/// <summary>Galaxy <c>Gateway.ApiKeySecretRef</c> (literal accepted for back-compat, with a warning at resolve).</summary>
|
||||
public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
|
||||
|
||||
@@ -59,7 +79,7 @@ public static class DriverCredentialPolicy
|
||||
[
|
||||
new("Password", CredentialLiteralPolicy.Allowed),
|
||||
new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
|
||||
]),
|
||||
], [OpcUaClientUserCertificatePathField]),
|
||||
};
|
||||
|
||||
/// <summary>The rules for a driver type, if it resolves credential references.</summary>
|
||||
@@ -110,7 +130,35 @@ public static class DriverCredentialPolicy
|
||||
return problems;
|
||||
}
|
||||
|
||||
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth)
|
||||
private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List<string> problems, int depth) =>
|
||||
Visit(element, path, rules, depth, (fieldPath, value, field, pathField) =>
|
||||
{
|
||||
if (field is not null)
|
||||
{
|
||||
var ok = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => CredentialReferences.IsAcceptable(value.GetString(), rules.Scope, field.Literals),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
|
||||
}
|
||||
else if (pathField is not null)
|
||||
{
|
||||
var ok = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => CredentialFilePaths.IsAcceptable(value.GetString()),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialFilePaths.RuleMessage(fieldPath, pathField.DirectoryDescription));
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>Walks every credential / path field of <paramref name="rules"/> at any depth, keys matched case-insensitively.</summary>
|
||||
private static void Visit(
|
||||
JsonElement element, string path, DriverCredentialRules rules, int depth,
|
||||
Action<string, JsonElement, CredentialField?, CredentialPathField?> onField)
|
||||
{
|
||||
if (depth > 32) return;
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
@@ -118,7 +166,7 @@ public static class DriverCredentialPolicy
|
||||
var i = 0;
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
Scan(item, $"{path}[{i}]", rules, problems, depth + 1);
|
||||
Visit(item, $"{path}[{i}]", rules, depth + 1, onField);
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -130,19 +178,141 @@ public static class DriverCredentialPolicy
|
||||
{
|
||||
var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
|
||||
var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (field is null)
|
||||
var pathField = field is null
|
||||
? rules.PathFieldList.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
if (field is null && pathField is null)
|
||||
{
|
||||
Scan(p.Value, fieldPath, rules, problems, depth + 1);
|
||||
Visit(p.Value, fieldPath, rules, depth + 1, onField);
|
||||
continue;
|
||||
}
|
||||
|
||||
var ok = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => true,
|
||||
JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals),
|
||||
_ => false,
|
||||
};
|
||||
if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
|
||||
onField(fieldPath, p.Value, field, pathField);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// lmxopcua#835 — cross-instance references inside one namespace
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The instance-identifying segment of an in-scope <c>secret:</c> reference: the first path segment after the
|
||||
/// matched namespace (<c>secret:galaxy/<X>/apikey</c> ⇒ <c>X</c>; <c>secret:otopcua/galaxy/<X></c> ⇒
|
||||
/// <c>X</c>). Null for anything else — <c>env:</c> references carry no instance convention, and literals and
|
||||
/// out-of-scope references are the #815 rule's business.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored field value.</param>
|
||||
/// <param name="scope">The driver's scope.</param>
|
||||
/// <returns>The segment, or null.</returns>
|
||||
public static string? InstanceSegment(string? value, CredentialScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
if (CredentialReferences.Parse(value, scope, out var name) != CredentialReferenceKind.Secret) return null;
|
||||
var ns = scope.SecretNamespaces
|
||||
.Where(n => name.StartsWith(n, StringComparison.Ordinal))
|
||||
.OrderByDescending(n => n.Length)
|
||||
.FirstOrDefault();
|
||||
if (ns is null) return null;
|
||||
var rest = name[ns.Length..];
|
||||
var slash = rest.IndexOf('/');
|
||||
return slash < 0 ? rest : rest[..slash];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the config carries at least one in-scope <c>secret:</c> reference — i.e. the cross-instance rule
|
||||
/// (<see cref="DescribeOtherInstanceProblems"/>) has something to check, so a seam must load the instance list.
|
||||
/// Lets seams skip the ConfigDb read otherwise.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <returns>Whether any instance segment is present.</returns>
|
||||
public static bool HasInstanceSegments(string? driverType, string? json) =>
|
||||
CollectSegments(driverType, json).Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#835: inside one namespace, a reference whose instance-identifying segment (see
|
||||
/// <see cref="InstanceSegment"/>) names a DIFFERENT existing instance of the same driver type — by its
|
||||
/// <see cref="DriverInstanceIdentity.DriverInstanceId"/> or its <see cref="DriverInstanceIdentity.Name"/>,
|
||||
/// case-insensitively — is refused: otherwise a Designer could create (or Test Connect / browse) a driver that
|
||||
/// points at their own endpoint with <c>secret:galaxy/<other-instance>/apikey</c> and capture that
|
||||
/// instance's key. Shared names that are no other instance's id or name (<c>secret:galaxy/gw/apikey</c>,
|
||||
/// <c>secret:galaxy/apikey</c>) stay valid, as does a reference naming the instance itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A match against ANOTHER instance wins over a match against the owner — names are not unique, and renaming
|
||||
/// one's own driver after a victim's must not unlock the victim's secret. <paramref name="ownDriverInstanceId"/>
|
||||
/// is null for a driver that does not exist yet (every listed instance is then "another").
|
||||
/// </remarks>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The DriverConfig / DeviceConfig / merged config JSON.</param>
|
||||
/// <param name="ownDriverInstanceId">The driver the config belongs to; null when it is being created.</param>
|
||||
/// <param name="instances">The known driver instances (any type; the owner may be included).</param>
|
||||
/// <returns>The problems; empty when acceptable, unreadable, or the type has no rules.</returns>
|
||||
public static IReadOnlyList<string> DescribeOtherInstanceProblems(
|
||||
string? driverType, string? json, string? ownDriverInstanceId, IEnumerable<DriverInstanceIdentity> instances)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(instances);
|
||||
var problems = new List<string>();
|
||||
if (!TryGet(driverType, out var rules)) return problems;
|
||||
var segments = CollectSegments(driverType, json);
|
||||
if (segments.Count == 0) return problems;
|
||||
|
||||
var others = instances
|
||||
.Where(i => string.Equals(i.DriverType, rules.DriverType, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(i.DriverInstanceId, ownDriverInstanceId, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
foreach (var (fieldPath, segment) in segments)
|
||||
{
|
||||
var other = others.FirstOrDefault(o =>
|
||||
string.Equals(o.DriverInstanceId, segment, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(o.Name, segment, StringComparison.OrdinalIgnoreCase));
|
||||
if (other is null) continue;
|
||||
problems.Add(
|
||||
$"{fieldPath} names a secret under '{segment}/', which is another {rules.Scope.Label} driver instance "
|
||||
+ $"('{other.Name}', {other.DriverInstanceId}) — a driver may reference only its own secrets "
|
||||
+ $"(secret:{rules.Scope.SecretNamespaces[0]}<this-driver-id>/…) or a shared name that is no other "
|
||||
+ "instance's id or name (lmxopcua#835).");
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both rules at once — <see cref="DescribeProblems(string?, string?)"/> (namespace, literals, path fields) followed
|
||||
/// by <see cref="DescribeOtherInstanceProblems"/> — as every seam that knows the owner and the instance list applies them.
|
||||
/// </summary>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="json">The config JSON.</param>
|
||||
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
|
||||
/// <param name="instances">The known driver instances.</param>
|
||||
/// <returns>The problems.</returns>
|
||||
public static IReadOnlyList<string> DescribeProblems(
|
||||
string? driverType, string? json, string? ownDriverInstanceId, IEnumerable<DriverInstanceIdentity> instances) =>
|
||||
[.. DescribeProblems(driverType, json), .. DescribeOtherInstanceProblems(driverType, json, ownDriverInstanceId, instances)];
|
||||
|
||||
private static List<(string FieldPath, string Segment)> CollectSegments(string? driverType, string? json)
|
||||
{
|
||||
var found = new List<(string, string)>();
|
||||
if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return found;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return found; // DescribeProblems reports unreadable JSON.
|
||||
}
|
||||
|
||||
using (doc)
|
||||
{
|
||||
Visit(doc.RootElement, "", rules, 0, (fieldPath, value, field, _) =>
|
||||
{
|
||||
if (field is null || value.ValueKind != JsonValueKind.String) return;
|
||||
if (InstanceSegment(value.GetString(), rules.Scope) is { Length: > 0 } segment) found.Add((fieldPath, segment));
|
||||
});
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,22 +61,44 @@ public sealed class LegacyCredentialReferenceTracker(
|
||||
public void Report(string field)
|
||||
{
|
||||
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
|
||||
Record(
|
||||
field,
|
||||
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}",
|
||||
$"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} "
|
||||
+ $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only "
|
||||
+ $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate "
|
||||
+ $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\".");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the key-material file field <paramref name="field"/> (lmxopcua#836, e.g. OpcUaClient
|
||||
/// <c>UserCertificatePath</c>) was loaded from a path outside the node's certificate directory through the same
|
||||
/// warn-only window (warns, rate-limited). Never carries the path.
|
||||
/// </summary>
|
||||
/// <param name="field">The field label.</param>
|
||||
/// <param name="directoryDescription">How the operator configures the directory.</param>
|
||||
public void ReportPath(string field, string directoryDescription) =>
|
||||
Record(
|
||||
field,
|
||||
$"path outside the certificate directory still loaded (#836 warn-only, deprecated); next deploy refuses it — "
|
||||
+ $"move the file under {directoryDescription} and author it as a relative path",
|
||||
$"{scope.Label} driver '{driverInstanceId}': {field} is not a relative path inside the node's certificate "
|
||||
+ $"directory ({directoryDescription}). It is still loaded for this already-deployed driver (lmxopcua#836 "
|
||||
+ "warn-only compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: move "
|
||||
+ "the file into that directory and set the field to its relative path before the next deploy. See "
|
||||
+ "docs/security.md §\"Key-material file paths are confined to a node directory\".");
|
||||
|
||||
private void Record(string field, string diagnostic, string warning)
|
||||
{
|
||||
var now = (clock ?? (() => DateTime.UtcNow))();
|
||||
bool log;
|
||||
lock (_gate)
|
||||
{
|
||||
_diagnostics[DiagnosticsKeyPrefix + field] =
|
||||
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}";
|
||||
_diagnostics[DiagnosticsKeyPrefix + field] = diagnostic;
|
||||
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
|
||||
if (log) _lastWarned[field] = now;
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
warn($"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} "
|
||||
+ $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only "
|
||||
+ $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate "
|
||||
+ $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\".");
|
||||
}
|
||||
if (log) warn(warning);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -234,12 +234,17 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <param name="userCertificateDirectory">
|
||||
/// Test seam: the user-certificate directory (lmxopcua#836); null ⇒ the node's configured one. The browse never
|
||||
/// passes a legacy tracker, so a pre-#836 absolute <c>UserCertificatePath</c> is refused here (hard-fail).
|
||||
/// </param>
|
||||
/// <returns>The identity the browse session activates with.</returns>
|
||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct, string? userCertificateDirectory = null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
opts, secretResolver, ct, userCertificateDirectory: userCertificateDirectory).ConfigureAwait(false);
|
||||
return OpcUaUserIdentityFactory.Build(resolved);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The node's OPC UA client user-certificate directory (lmxopcua#836). <c>UserCertificatePath</c> is authored as a
|
||||
/// path RELATIVE to it and resolved by <see cref="CredentialFilePaths.ResolveWithin"/> (lexical + link-resolved
|
||||
/// containment), so a Designer can no longer make the node present an arbitrary PFX the service account can read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configured once per process from <see cref="DriverCredentialPolicy.OpcUaClientUserCertificateDirectoryKey"/>
|
||||
/// (<c>Drivers:OpcUaClient:UserCertificateDirectory</c>, env <c>Drivers__OpcUaClient__UserCertificateDirectory</c>)
|
||||
/// by the host at startup; unset ⇒ <see cref="DefaultDirectory"/>, beside the driver's PKI stores. Both the runtime
|
||||
/// driver and the AdminUI browser read it, so admin and driver nodes must hold the same relative layout.
|
||||
/// </remarks>
|
||||
public static class OpcUaUserCertificatePaths
|
||||
{
|
||||
private static volatile string? _configured;
|
||||
|
||||
/// <summary>The default: <c><LocalAppData>/OtOpcUa/pki/user-certificates</c>.</summary>
|
||||
public static string DefaultDirectory => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki", "user-certificates");
|
||||
|
||||
/// <summary>The directory in force (configured, else <see cref="DefaultDirectory"/>).</summary>
|
||||
public static string CurrentDirectory => _configured ?? DefaultDirectory;
|
||||
|
||||
/// <summary>Sets the process-wide directory (blank ⇒ the default). Called by the host at startup.</summary>
|
||||
/// <param name="directory">The configured directory.</param>
|
||||
public static void Configure(string? directory) =>
|
||||
_configured = string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an authored <c>UserCertificatePath</c> to the file to load, fail-closed
|
||||
/// (<see cref="CredentialReferenceException"/>). A fully-qualified (pre-#836) path is refused unless
|
||||
/// <paramref name="legacy"/> is given (the RUNNING driver only — deprecated warn-only window), in which case it is
|
||||
/// reported and returned verbatim, exactly as before #836. Anything else that is not an acceptable relative path is
|
||||
/// always refused.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored path.</param>
|
||||
/// <param name="directory">The directory; null ⇒ <see cref="CurrentDirectory"/>.</param>
|
||||
/// <param name="legacy">The running driver's tracker; null ⇒ hard-fail.</param>
|
||||
/// <returns>The path to load.</returns>
|
||||
public static string Resolve(string value, string? directory = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
var field = DriverCredentialPolicy.OpcUaClientUserCertificatePathField;
|
||||
// Only a fully-qualified path is a pre-#836 value worth keeping alive: a relative one resolved against the
|
||||
// service's working directory, which was never a meaningful configuration.
|
||||
if (legacy is not null && !CredentialFilePaths.IsAcceptableRelative(value) && Path.IsPathFullyQualified(value))
|
||||
{
|
||||
legacy.ReportPath(field.Name, field.DirectoryDescription);
|
||||
return value;
|
||||
}
|
||||
|
||||
return CredentialFilePaths.ResolveWithin(
|
||||
value, directory ?? CurrentDirectory, $"OpcUaClientDriverOptions.{field.Name}", field.DirectoryDescription);
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -81,6 +81,12 @@ public static class OpcUaUserIdentityFactory
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
// lmxopcua#836: an authored (relative) path is only ever loaded after ResolveSecretRefsAsync resolved it inside the
|
||||
// node's user-certificate directory — never against the process's working directory.
|
||||
if (!Path.IsPathFullyQualified(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClientDriverOptions.UserCertificatePath has not been resolved against the node's user-certificate "
|
||||
+ "directory (ResolveSecretRefsAsync) — refusing to load a relative path (fail-closed).");
|
||||
if (!File.Exists(options.UserCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
@@ -120,13 +126,26 @@ public static class OpcUaUserIdentityFactory
|
||||
/// out-of-namespace value keeps the old semantics (a case-insensitive <c>secret:</c> resolves, anything else is
|
||||
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
|
||||
/// </param>
|
||||
/// <param name="userCertificateDirectory">
|
||||
/// lmxopcua#836: the directory a <see cref="OpcUaAuthType.Certificate"/> identity's
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/> is resolved inside (relative path, lexical +
|
||||
/// link-resolved containment); null ⇒ <see cref="OpcUaUserCertificatePaths.CurrentDirectory"/>. The returned
|
||||
/// options carry the resolved full path. An absolute (pre-#836) path is refused unless <paramref name="legacy"/>
|
||||
/// is given, in which case it is loaded as before with a warn-only report.
|
||||
/// </param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null,
|
||||
string? userCertificateDirectory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// #836 first: a refused certificate path fails before any secret/env read.
|
||||
var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath)
|
||||
? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory, legacy)
|
||||
: options.UserCertificatePath;
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
@@ -134,12 +153,13 @@ public static class OpcUaUserIdentityFactory
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword)
|
||||
&& string.Equals(certPath, options.UserCertificatePath, StringComparison.Ordinal))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
return options with { Password = password, UserCertificatePassword = certPassword, UserCertificatePath = certPath };
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
@@ -20,13 +23,18 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
|
||||
/// <param name="universalBrowser">The Discover-backed fallback browser.</param>
|
||||
/// <param name="authorizationService">Policy evaluator for <see cref="RequestRebirthAsync"/>.</param>
|
||||
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
|
||||
/// <param name="dbFactory">
|
||||
/// The config DB, for the cross-instance credential rule (lmxopcua#835). Null (test graphs) ⇒ a config carrying an
|
||||
/// in-scope <c>secret:</c> reference cannot be checked and is refused (fail-closed).
|
||||
/// </param>
|
||||
public sealed class BrowserSessionService(
|
||||
IEnumerable<IDriverBrowser> browsers,
|
||||
BrowseSessionRegistry registry,
|
||||
ILogger<BrowserSessionService> logger,
|
||||
IUniversalDriverBrowser universalBrowser,
|
||||
IAuthorizationService authorizationService,
|
||||
AuthenticationStateProvider authenticationStateProvider) : IBrowserSessionService
|
||||
AuthenticationStateProvider authenticationStateProvider,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null) : IBrowserSessionService
|
||||
{
|
||||
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
|
||||
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
|
||||
@@ -35,7 +43,11 @@ public sealed class BrowserSessionService(
|
||||
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
||||
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) =>
|
||||
OpenAsync(driverType, configJson, null, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, string? driverInstanceId, CancellationToken ct)
|
||||
{
|
||||
// lmxopcua#815: a live browse resolves the config's credential references and sends them to the
|
||||
// author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
|
||||
@@ -44,6 +56,19 @@ public sealed class BrowserSessionService(
|
||||
if (credentialProblems.Count > 0)
|
||||
return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
|
||||
|
||||
// lmxopcua#835: nor may an in-namespace reference name ANOTHER driver instance's secret.
|
||||
if (DriverCredentialPolicy.HasInstanceSegments(driverType, configJson))
|
||||
{
|
||||
if (dbFactory is null)
|
||||
return new(false, "Browse refused: the driver-instance list needed to check this config's secret: "
|
||||
+ "references is unavailable (lmxopcua#835, fail-closed).", Guid.Empty);
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct).ConfigureAwait(false);
|
||||
var instanceProblems = await DriverInstanceIdentities
|
||||
.DescribeOtherInstanceProblemsAsync(db, driverType, configJson, driverInstanceId, ct).ConfigureAwait(false);
|
||||
if (instanceProblems.Count > 0)
|
||||
return new(false, "Browse refused: " + string.Join(" ", instanceProblems), Guid.Empty);
|
||||
}
|
||||
|
||||
if (!_browsersByType.TryGetValue(driverType, out var browser))
|
||||
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
|
||||
try
|
||||
|
||||
@@ -27,6 +27,19 @@ public interface IBrowserSessionService
|
||||
/// <returns>The result of opening the session, including the registry token on success.</returns>
|
||||
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="OpenAsync(string, string, CancellationToken)"/>, naming the driver instance the config belongs to
|
||||
/// (lmxopcua#835: its own <c>secret:<ns>/<id>/…</c> references are allowed, another instance's are refused;
|
||||
/// null ⇒ a not-yet-created instance, which is stricter). The default forwards without the id (test doubles).
|
||||
/// </summary>
|
||||
/// <param name="driverType">The driver type to open a browse session against.</param>
|
||||
/// <param name="configJson">The driver configuration as JSON.</param>
|
||||
/// <param name="driverInstanceId">The owning driver instance, or null.</param>
|
||||
/// <param name="ct">Cancellation token for the operation.</param>
|
||||
/// <returns>The result of opening the session, including the registry token on success.</returns>
|
||||
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, string? driverInstanceId, CancellationToken ct) =>
|
||||
OpenAsync(driverType, configJson, ct);
|
||||
|
||||
/// <summary>Returns the root nodes of an open session. Throws
|
||||
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
|
||||
/// <param name="token">Registry handle for the open browse session.</param>
|
||||
|
||||
@@ -25,15 +25,17 @@ public sealed class AdminProbeService
|
||||
/// <param name="configJson">Driver config as JSON (same shape as <c>DriverInstance.DriverConfig</c>).</param>
|
||||
/// <param name="timeoutSeconds">Per-probe timeout; actor clamps to [1, 60].</param>
|
||||
/// <param name="ct">Optional cancellation token from the caller.</param>
|
||||
/// <param name="driverInstanceId">The owning driver instance (lmxopcua#835); null ⇒ a not-yet-created one.</param>
|
||||
/// <returns>The probe result, or a timeout failure if no reply arrives in time.</returns>
|
||||
public async Task<TestDriverConnectResult> TestAsync(
|
||||
string driverType,
|
||||
string configJson,
|
||||
int timeoutSeconds,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default,
|
||||
string? driverInstanceId = null)
|
||||
{
|
||||
var correlationId = Guid.NewGuid();
|
||||
var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId);
|
||||
var msg = new TestDriverConnect(driverType, configJson, timeoutSeconds, correlationId, driverInstanceId);
|
||||
|
||||
// 65s outer guard — the actor's CTS clamps to 60s; if the Ask never returns we still want
|
||||
// a deterministic failure surface for the UI.
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
|
||||
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" DriverInstanceId="@_resolvedDriverInstanceId" />
|
||||
</div>
|
||||
|
||||
@if (DriverConfigSaveGate.Validate(_driverType, _deviceConfigJson) is { } gateError)
|
||||
|
||||
+7
-1
@@ -60,6 +60,12 @@
|
||||
/// </summary>
|
||||
[Parameter] public int TimeoutSeconds { get; set; } = TestConnectBudget.DefaultSeconds;
|
||||
|
||||
/// <summary>
|
||||
/// The driver instance the config belongs to (lmxopcua#835: its own <c>secret:<ns>/<id>/…</c> references are
|
||||
/// allowed, another instance's are refused). Null ⇒ treated as a not-yet-created instance (stricter).
|
||||
/// </summary>
|
||||
[Parameter] public string? DriverInstanceId { get; set; }
|
||||
|
||||
private const int ClearAfterMs = 30_000;
|
||||
|
||||
private bool _busy;
|
||||
@@ -83,7 +89,7 @@
|
||||
var json = GetConfigJson() ?? "{}";
|
||||
var budget = TestConnectBudget.Resolve(json, TimeoutSeconds, DriverType);
|
||||
_lastBudgetSeconds = budget;
|
||||
_result = await Probe.TestAsync(DriverType, json, budget);
|
||||
_result = await Probe.TestAsync(DriverType, json, budget, driverInstanceId: DriverInstanceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+2
-1
@@ -146,7 +146,8 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">User certificate path (PFX/PEM)</label>
|
||||
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
|
||||
placeholder="C:\certs\user.pfx" />
|
||||
placeholder="plant1/user.pfx" />
|
||||
<div class="form-text">Relative path inside the node's user-certificate directory (<code>Drivers:OpcUaClient:UserCertificateDirectory</code>, default <code><LocalAppData>/OtOpcUa/pki/user-certificates</code>). Absolute paths and <code>..</code> are refused (#836).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Certificate password (if PFX-locked)</label>
|
||||
|
||||
@@ -463,7 +463,12 @@
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
|
||||
// lmxopcua#835: name the owning driver so its own secret:<ns>/<id>/… references are told apart from
|
||||
// another instance's (an unresolvable owner is treated as a new driver — stricter).
|
||||
var ownerDriverId = string.IsNullOrEmpty(_effectiveDeviceId)
|
||||
? null
|
||||
: await Svc.LoadDeviceDriverInstanceIdAsync(_effectiveDeviceId);
|
||||
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, ownerDriverId, default);
|
||||
// Host disposed, modal closed, or modal re-opened mid-open: close the session nobody will (R04 L4).
|
||||
if (BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)) { return; }
|
||||
if (result.Ok)
|
||||
|
||||
@@ -77,22 +77,26 @@ public sealed class CredentialReferencePreflight(IDbContextFactory<OtOpcUaConfig
|
||||
var findings = new List<CredentialReferenceFinding>();
|
||||
var driverList = drivers.ToList();
|
||||
var byId = driverList.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
||||
// lmxopcua#835: the cross-instance rule is checked against the instance set of the same source.
|
||||
var draftInstances = driverList.Select(d => new DriverInstanceIdentity(d.DriverInstanceId, d.Name, d.DriverType)).ToList();
|
||||
foreach (var d in driverList)
|
||||
{
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig))
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(d.DriverType, d.DriverConfig, d.DriverInstanceId, draftInstances))
|
||||
findings.Add(new(CredentialFindingSource.Draft, d.DriverType, d.Name, d.DriverInstanceId, null, p));
|
||||
}
|
||||
|
||||
foreach (var dev in devices)
|
||||
{
|
||||
if (!byId.TryGetValue(dev.DriverInstanceId, out var owner)) continue;
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig))
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(owner.DriverType, dev.DeviceConfig, owner.DriverInstanceId, draftInstances))
|
||||
findings.Add(new(CredentialFindingSource.Draft, owner.DriverType, owner.Name, owner.DriverInstanceId, dev.Name, p));
|
||||
}
|
||||
|
||||
foreach (var s in deployed)
|
||||
var deployedList = deployed.ToList();
|
||||
var deployedInstances = deployedList.Select(s => new DriverInstanceIdentity(s.DriverInstanceId, s.Name, s.DriverType)).ToList();
|
||||
foreach (var s in deployedList)
|
||||
{
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig))
|
||||
foreach (var p in DriverCredentialPolicy.DescribeProblems(s.DriverType, s.DriverConfig, s.DriverInstanceId, deployedInstances))
|
||||
findings.Add(new(CredentialFindingSource.Deployed, s.DriverType, s.Name, s.DriverInstanceId, null, p));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
@@ -26,4 +28,24 @@ public static class DriverConfigSaveGate
|
||||
var problems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authority check <see cref="RawTreeService"/> runs on every driver/device create + update: <see cref="Validate"/>
|
||||
/// plus the cross-instance rule (lmxopcua#835 — a <c>secret:galaxy/<X>/…</c> reference may not name ANOTHER
|
||||
/// existing instance of the same driver type), which needs the stored instance list.
|
||||
/// </summary>
|
||||
/// <param name="db">The config DB (read only when the config carries an in-scope <c>secret:</c> reference).</param>
|
||||
/// <param name="driverType">The owning driver's type.</param>
|
||||
/// <param name="configJson">The DriverConfig or DeviceConfig JSON about to be stored.</param>
|
||||
/// <param name="ownDriverInstanceId">The owning driver; null when it is being created.</param>
|
||||
/// <param name="ct">Cancellation.</param>
|
||||
/// <returns>The refusal (never containing a credential value), or null.</returns>
|
||||
public static async Task<string?> ValidateAsync(
|
||||
OtOpcUaConfigDbContext db, string? driverType, string? configJson, string? ownDriverInstanceId, CancellationToken ct)
|
||||
{
|
||||
if (Validate(driverType, configJson) is { } refusal) return refusal;
|
||||
var problems = await DriverInstanceIdentities
|
||||
.DescribeOtherInstanceProblemsAsync(db, driverType, configJson, ownDriverInstanceId, ct).ConfigureAwait(false);
|
||||
return problems.Count == 0 ? null : string.Join(" ", problems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,4 +394,15 @@ public interface IRawTreeService
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved.</returns>
|
||||
Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The driver instance owning <paramref name="deviceId"/>, or null when it cannot be resolved — handed to the live
|
||||
/// browse so the cross-instance credential rule (lmxopcua#835) can tell the driver's own secrets from another
|
||||
/// instance's. The default (test doubles) returns null, which is the stricter "not-yet-created" treatment.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The owning driver instance id, or null.</returns>
|
||||
Task<string?> LoadDeviceDriverInstanceIdAsync(string deviceId, CancellationToken ct = default) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
@@ -488,6 +488,10 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
// lmxopcua#835: a new driver owns no secrets yet — a reference naming any existing instance is refused.
|
||||
if (await DriverConfigSaveGate.ValidateAsync(db, driverType, driverConfigJson, null, ct) is { } instanceRefusal)
|
||||
return new UnsMutationResult(false, instanceRefusal);
|
||||
|
||||
if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
|
||||
return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");
|
||||
|
||||
@@ -621,7 +625,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var owner = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (owner is null)
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
||||
if (DriverConfigSaveGate.Validate(owner.DriverType, deviceConfigJson) is { } configRefusal)
|
||||
if (await DriverConfigSaveGate.ValidateAsync(db, owner.DriverType, deviceConfigJson, owner.DriverInstanceId, ct) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
// MTConnect owns one agent client per driver — mirror the deploy gate
|
||||
@@ -1116,7 +1120,7 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
if (DriverConfigSaveGate.Validate(entity.DriverType, driverConfigJson) is { } configRefusal)
|
||||
if (await DriverConfigSaveGate.ValidateAsync(db, entity.DriverType, driverConfigJson, entity.DriverInstanceId, ct) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
// Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder).
|
||||
@@ -1178,7 +1182,8 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
|
||||
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
||||
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
||||
if (DriverConfigSaveGate.Validate(await ResolveDriverTypeAsync(db, deviceId, ct), deviceConfigJson) is { } configRefusal)
|
||||
if (await DriverConfigSaveGate.ValidateAsync(
|
||||
db, await ResolveDriverTypeAsync(db, deviceId, ct), deviceConfigJson, entity.DriverInstanceId, ct) is { } configRefusal)
|
||||
return new UnsMutationResult(false, configRefusal);
|
||||
|
||||
if (await db.Devices.AnyAsync(
|
||||
@@ -1201,6 +1206,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> LoadDeviceDriverInstanceIdAsync(string deviceId, CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
return await db.Devices.AsNoTracking()
|
||||
.Where(dev => dev.DeviceId == deviceId)
|
||||
.Select(dev => dev.DriverInstanceId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
|
||||
string deviceId, CancellationToken ct = default)
|
||||
|
||||
@@ -611,6 +611,19 @@ public sealed class AdminOperationsActor : ReceiveActor
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
// lmxopcua#835: a reference naming ANOTHER driver instance's secret (secret:galaxy/<other>/…) is refused
|
||||
// before the probe resolves it — off the mailbox, since it reads the ConfigDb (only when the config
|
||||
// carries an in-scope secret: reference at all).
|
||||
if (DriverCredentialPolicy.HasInstanceSegments(msg.DriverType, msg.ConfigJson))
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cts.Token);
|
||||
var instanceProblems = await DriverInstanceIdentities.DescribeOtherInstanceProblemsAsync(
|
||||
db, msg.DriverType, msg.ConfigJson, msg.DriverInstanceId, cts.Token);
|
||||
if (instanceProblems.Count > 0)
|
||||
return new TestDriverConnectResult(
|
||||
false, "Test Connect refused: " + string.Join(" ", instanceProblems), null, msg.CorrelationId);
|
||||
}
|
||||
|
||||
var result = await probe.ProbeAsync(msg.ConfigJson, timeout, cts.Token);
|
||||
sw.Stop();
|
||||
return new TestDriverConnectResult(
|
||||
|
||||
@@ -194,6 +194,10 @@ builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddAbCipDriverDefaults(builder.Configuration);
|
||||
// lmxopcua#836: the directory OpcUaClient UserCertificatePath (a relative path) is confined to. Unconditional for the
|
||||
// same reason — the driver (driver role) and the AdminUI live browse (admin role) both load user certificates.
|
||||
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserCertificatePaths.Configure(
|
||||
builder.Configuration[ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverCredentialPolicy.OpcUaClientUserCertificateDirectoryKey]);
|
||||
// lmxopcua#765: refresh/staleness of the cached API-key → Galaxy ack identity set (driver nodes consume it).
|
||||
builder.Services.AddGalaxyAckIdentityCache(builder.Configuration);
|
||||
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The deploy gate for lmxopcua#835 (a credential reference naming ANOTHER driver instance's secret) and #836 (the
|
||||
/// OpcUaClient user-certificate path confined to the node's certificate directory).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DraftValidatorCredentialInstanceAndPathTests
|
||||
{
|
||||
private static IReadOnlyList<ValidationError> Validate(params DriverInstance[] drivers) =>
|
||||
Validate(drivers, []);
|
||||
|
||||
private static IReadOnlyList<ValidationError> Validate(DriverInstance[] drivers, Device[] devices) =>
|
||||
DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1,
|
||||
ClusterId = "c",
|
||||
DriverInstances = drivers,
|
||||
Devices = devices,
|
||||
Tags = [],
|
||||
}).Where(e => e.Code is CredentialReferenceDeployChecks.Code or CredentialReferenceDeployChecks.OtherInstanceCode).ToList();
|
||||
|
||||
private static DriverInstance Drv(string id, string name, string type, string config) =>
|
||||
new() { DriverInstanceId = id, ClusterId = "c", Name = name, DriverType = type, DriverConfig = config };
|
||||
|
||||
[Fact]
|
||||
public void A_galaxy_driver_naming_another_instances_key_is_refused()
|
||||
{
|
||||
var e = Validate(
|
||||
Drv("DRV-victim", "Victim", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}"""),
|
||||
Drv("DRV-evil", "Evil", "GalaxyMxGateway", """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}"""))
|
||||
.ShouldHaveSingleItem();
|
||||
e.Code.ShouldBe(CredentialReferenceDeployChecks.OtherInstanceCode);
|
||||
e.Context.ShouldBe("DRV-evil");
|
||||
e.Message.ShouldContain("lmxopcua#835");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_device_config_naming_another_instance_by_name_is_refused() =>
|
||||
Validate(
|
||||
[
|
||||
Drv("DRV-a", "Victim", "OpcUaClient", "{}"),
|
||||
Drv("DRV-b", "Evil", "OpcUaClient", "{}"),
|
||||
],
|
||||
[new Device { DeviceId = "dev-b", DriverInstanceId = "DRV-b", Name = "d", DeviceConfig = """{"Password":"secret:opcuaclient/victim/pw"}""" }])
|
||||
.ShouldHaveSingleItem().Context.ShouldBe("dev-b");
|
||||
|
||||
[Fact]
|
||||
public void Shared_and_own_keys_pass() =>
|
||||
Validate(
|
||||
Drv("DRV-a", "A", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-a/apikey"}}"""),
|
||||
Drv("DRV-b", "B", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/apikey"}}"""),
|
||||
Drv("DRV-c", "C", "GalaxyMxGateway", """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/apikey"}}"""))
|
||||
.ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void Heidenhain_cross_instance_is_reported_by_this_gate() =>
|
||||
DraftValidator.Validate(new DraftSnapshot
|
||||
{
|
||||
GenerationId = 1, ClusterId = "c",
|
||||
DriverInstances =
|
||||
[
|
||||
Drv("H1", "line1", "Heidenhain", """{"Access":{"InspectPassword":"secret:heidenhain/line1/inspect"}}"""),
|
||||
Drv("H2", "line2", "Heidenhain", """{"Access":{"InspectPassword":"secret:heidenhain/line1/inspect"}}"""),
|
||||
],
|
||||
Devices = [], Tags = [],
|
||||
})
|
||||
.Where(e => e.Code == CredentialReferenceDeployChecks.OtherInstanceCode)
|
||||
.ShouldHaveSingleItem().Context.ShouldBe("H2");
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"C:\\Windows\\System32\\config\\systemprofile\\host.pfx"}""")]
|
||||
[InlineData("""{"UserCertificatePath":"/etc/ssl/private/host.pfx"}""")]
|
||||
[InlineData("""{"UserCertificatePath":"plant1/../../host.pfx"}""")]
|
||||
public void An_opcuaclient_user_certificate_path_outside_the_directory_is_refused(string config)
|
||||
{
|
||||
var e = Validate(Drv("DRV-o", "o", "OpcUaClient", config)).ShouldHaveSingleItem();
|
||||
e.Code.ShouldBe(CredentialReferenceDeployChecks.Code);
|
||||
e.Message.ShouldContain("lmxopcua#836");
|
||||
e.Message.ShouldNotContain("host.pfx");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_relative_user_certificate_path_passes() =>
|
||||
Validate(Drv("DRV-o", "o", "OpcUaClient", """{"AuthType":"Certificate","UserCertificatePath":"plant1/user.pfx"}""")).ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#836: a key-material file field (OpcUaClient <c>UserCertificatePath</c>) is a relative path confined to a
|
||||
/// node directory — syntactically at the admin seams, lexically + through links at load time.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialFilePathsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("user.pfx")]
|
||||
[InlineData("plant1/user.pfx")]
|
||||
[InlineData("plant1\\user.pfx")]
|
||||
[InlineData("a/b-c_d.e/f.pfx")]
|
||||
public void Plain_relative_paths_are_acceptable(string path) => CredentialFilePaths.IsAcceptableRelative(path).ShouldBeTrue();
|
||||
|
||||
[Theory]
|
||||
[InlineData("/etc/ssl/private/host.pfx")]
|
||||
[InlineData("\\\\server\\share\\x.pfx")]
|
||||
[InlineData("\\x.pfx")]
|
||||
[InlineData("C:\\certs\\user.pfx")]
|
||||
[InlineData("C:user.pfx")]
|
||||
[InlineData("../user.pfx")]
|
||||
[InlineData("plant1/../../user.pfx")]
|
||||
[InlineData("./user.pfx")]
|
||||
[InlineData("plant1//user.pfx")]
|
||||
[InlineData("plant1/")]
|
||||
[InlineData("~/user.pfx")]
|
||||
[InlineData("user pfx")]
|
||||
[InlineData("secret:opcuaclient/x")]
|
||||
public void Absolute_traversal_and_odd_paths_are_refused(string path)
|
||||
{
|
||||
CredentialFilePaths.IsAcceptableRelative(path).ShouldBeFalse();
|
||||
CredentialFilePaths.IsAcceptable(path).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_is_acceptable_but_not_a_relative_path()
|
||||
{
|
||||
CredentialFilePaths.IsAcceptable(null).ShouldBeTrue();
|
||||
CredentialFilePaths.IsAcceptable("").ShouldBeTrue();
|
||||
CredentialFilePaths.IsAcceptableRelative("").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"UserCertificatePath":"/etc/ssl/private/host.pfx"}""", 1)]
|
||||
[InlineData("""{"userCertificatePath":"..\\..\\Windows\\x.pfx"}""", 1)]
|
||||
[InlineData("""{"Devices":[{"UserCertificatePath":"C:\\x.pfx"}]}""", 1)]
|
||||
[InlineData("""{"UserCertificatePath":42}""", 1)]
|
||||
[InlineData("""{"UserCertificatePath":"plant1/user.pfx"}""", 0)]
|
||||
[InlineData("""{"UserCertificatePath":null}""", 0)]
|
||||
public void The_driver_policy_scans_the_OpcUaClient_path_field(string config, int expected)
|
||||
{
|
||||
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.OpcUaClient, config);
|
||||
problems.Count.ShouldBe(expected);
|
||||
if (expected > 0)
|
||||
{
|
||||
problems[0].ShouldContain("lmxopcua#836");
|
||||
problems[0].ShouldContain(DriverCredentialPolicy.OpcUaClientUserCertificateDirectoryKey);
|
||||
problems[0].ShouldNotContain("host.pfx");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_drivers_do_not_scan_the_path_field() =>
|
||||
DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, """{"UserCertificatePath":"/etc/x.pfx"}""").ShouldBeEmpty();
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithin_returns_the_path_inside_the_directory()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
var resolved = CredentialFilePaths.ResolveWithin("plant1/user.pfx", dir.Path, "F", "D");
|
||||
resolved.ShouldBe(Path.Combine(Path.GetFullPath(dir.Path), "plant1", "user.pfx"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../escape.pfx")]
|
||||
[InlineData("/etc/passwd")]
|
||||
public void ResolveWithin_refuses_a_bad_value_fail_closed(string value)
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
Should.Throw<CredentialReferenceException>(() => CredentialFilePaths.ResolveWithin(value, dir.Path, "F", "D"))
|
||||
.Message.ShouldContain("fail-closed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithin_refuses_a_file_symlink_that_escapes_the_directory()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
using var outside = new TempDir();
|
||||
var target = Path.Combine(outside.Path, "victim.pfx");
|
||||
File.WriteAllText(target, "x");
|
||||
var link = Path.Combine(dir.Path, "user.pfx");
|
||||
if (!TryCreateLink(() => File.CreateSymbolicLink(link, target))) return;
|
||||
|
||||
Should.Throw<CredentialReferenceException>(() => CredentialFilePaths.ResolveWithin("user.pfx", dir.Path, "F", "D"))
|
||||
.Message.ShouldContain("symbolic link");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithin_refuses_a_directory_symlink_that_escapes_the_directory()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
using var outside = new TempDir();
|
||||
File.WriteAllText(Path.Combine(outside.Path, "victim.pfx"), "x");
|
||||
if (!TryCreateLink(() => Directory.CreateSymbolicLink(Path.Combine(dir.Path, "plant1"), outside.Path))) return;
|
||||
|
||||
Should.Throw<CredentialReferenceException>(() => CredentialFilePaths.ResolveWithin("plant1/victim.pfx", dir.Path, "F", "D"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithin_accepts_a_symlink_that_stays_inside_the_directory()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
Directory.CreateDirectory(Path.Combine(dir.Path, "real"));
|
||||
File.WriteAllText(Path.Combine(dir.Path, "real", "user.pfx"), "x");
|
||||
if (!TryCreateLink(() => File.CreateSymbolicLink(Path.Combine(dir.Path, "alias.pfx"), Path.Combine(dir.Path, "real", "user.pfx")))) return;
|
||||
|
||||
CredentialFilePaths.ResolveWithin("alias.pfx", dir.Path, "F", "D").ShouldEndWith("alias.pfx");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithin_accepts_a_directory_that_is_itself_reached_through_a_link()
|
||||
{
|
||||
// e.g. macOS /var -> /private/var: the configured directory's own parents may be links.
|
||||
using var outside = new TempDir();
|
||||
using var holder = new TempDir();
|
||||
var linkedDir = Path.Combine(holder.Path, "certs");
|
||||
if (!TryCreateLink(() => Directory.CreateSymbolicLink(linkedDir, outside.Path))) return;
|
||||
File.WriteAllText(Path.Combine(outside.Path, "user.pfx"), "x");
|
||||
|
||||
CredentialFilePaths.ResolveWithin("user.pfx", linkedDir, "F", "D").ShouldEndWith("user.pfx");
|
||||
}
|
||||
|
||||
private static bool TryCreateLink(Action create)
|
||||
{
|
||||
try
|
||||
{
|
||||
create();
|
||||
return true;
|
||||
}
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return false; // Windows without the symlink privilege: the lexical tests still cover the rule.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TempDir : IDisposable
|
||||
{
|
||||
public TempDir()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "otopcua-836-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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/<X>/…</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"));
|
||||
}
|
||||
}
|
||||
+35
-3
@@ -45,7 +45,10 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
ex.Message.ShouldNotContain("does not support");
|
||||
}
|
||||
|
||||
/// <summary>A valid PFX whose password is a <c>secret:</c> ref resolves through the injected store.</summary>
|
||||
/// <summary>
|
||||
/// A valid PFX (a relative path inside the user-certificate directory — lmxopcua#836) whose password is a
|
||||
/// <c>secret:</c> ref resolves through the injected store.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_certificate_identity_is_built_with_its_secret_password_resolved()
|
||||
{
|
||||
@@ -55,12 +58,13 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = path,
|
||||
UserCertificatePath = Path.GetFileName(path),
|
||||
UserCertificatePassword = "secret:opcuaclient/browse-cert",
|
||||
};
|
||||
|
||||
var identity = await OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
||||
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken);
|
||||
opts, new OneSecret("opcuaclient/browse-cert", "s3cret-pw"), TestContext.Current.CancellationToken,
|
||||
userCertificateDirectory: Path.GetDirectoryName(path));
|
||||
|
||||
identity.TokenType.ShouldBe(UserTokenType.Certificate);
|
||||
}
|
||||
@@ -70,6 +74,34 @@ public sealed class OpcUaClientBrowserCertificateAuthTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#836: the browse is a hard-fail path — an absolute <c>UserCertificatePath</c> (any PFX the service account
|
||||
/// can read) is refused before it is loaded or its password resolved, even when the file exists.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_browse_refuses_an_absolute_user_certificate_path()
|
||||
{
|
||||
var path = WritePfx("s3cret-pw");
|
||||
try
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Certificate,
|
||||
UserCertificatePath = path,
|
||||
UserCertificatePassword = "s3cret-pw",
|
||||
};
|
||||
|
||||
var ex = await Should.ThrowAsync<ZB.MOM.WW.OtOpcUa.Core.Abstractions.CredentialReferenceException>(() =>
|
||||
OpcUaClientDriverBrowser.BuildBrowseUserIdentityAsync(
|
||||
opts, null, TestContext.Current.CancellationToken, userCertificateDirectory: Path.GetDirectoryName(path)));
|
||||
ex.Message.ShouldContain("lmxopcua#836");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>With no secret store, a <c>secret:</c> ref fails closed instead of being sent as the literal.</summary>
|
||||
[Fact]
|
||||
public async Task A_secret_ref_with_no_secret_store_fails_closed()
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#836: the user-certificate identity loads only a PFX inside the node's user-certificate directory — the
|
||||
/// authored <c>UserCertificatePath</c> is relative, resolved (and link-checked) by <c>ResolveSecretRefsAsync</c>, which
|
||||
/// both the running driver and the AdminUI browser call before building the identity.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientUserCertificatePathTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "otopcua-836-ucert-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly string _outside = Path.Combine(Path.GetTempPath(), "otopcua-836-outside-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public OpcUaClientUserCertificatePathTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_dir, "plant1"));
|
||||
Directory.CreateDirectory(_outside);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in new[] { _dir, _outside })
|
||||
try { Directory.Delete(d, recursive: true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
private static void WritePfx(string path)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=Issue836", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1));
|
||||
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, "pw"));
|
||||
}
|
||||
|
||||
private static OpcUaClientDriverOptions Cert(string path) =>
|
||||
new() { AuthType = OpcUaAuthType.Certificate, UserCertificatePath = path, UserCertificatePassword = "pw" };
|
||||
|
||||
[Fact]
|
||||
public async Task A_relative_path_inside_the_directory_resolves_and_loads()
|
||||
{
|
||||
WritePfx(Path.Combine(_dir, "plant1", "user.pfx"));
|
||||
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert("plant1/user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir);
|
||||
|
||||
resolved.UserCertificatePath.ShouldBe(Path.Combine(Path.GetFullPath(_dir), "plant1", "user.pfx"));
|
||||
OpcUaUserIdentityFactory.Build(resolved).TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../outside.pfx")]
|
||||
[InlineData("plant1/../../outside.pfx")]
|
||||
public async Task Traversal_is_refused_before_any_load(string path) =>
|
||||
await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert(path), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir));
|
||||
|
||||
[Fact]
|
||||
public async Task An_absolute_path_is_refused_on_the_hard_fail_path_even_inside_the_directory()
|
||||
{
|
||||
var abs = Path.Combine(_dir, "plant1", "user.pfx");
|
||||
WritePfx(abs);
|
||||
var ex = await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert(abs), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir));
|
||||
ex.Message.ShouldContain("lmxopcua#836");
|
||||
ex.Message.ShouldNotContain(abs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_symlink_escaping_the_directory_is_refused()
|
||||
{
|
||||
var victim = Path.Combine(_outside, "victim.pfx");
|
||||
WritePfx(victim);
|
||||
try
|
||||
{
|
||||
File.CreateSymbolicLink(Path.Combine(_dir, "user.pfx"), victim);
|
||||
}
|
||||
catch (Exception e) when (e is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
return; // no symlink privilege (Windows): covered lexically above
|
||||
}
|
||||
|
||||
await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert("user.pfx"), null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_running_driver_keeps_loading_a_legacy_absolute_path_and_warns()
|
||||
{
|
||||
var abs = Path.Combine(_outside, "legacy.pfx");
|
||||
WritePfx(abs);
|
||||
var warnings = new List<string>();
|
||||
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add);
|
||||
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert(abs), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir);
|
||||
|
||||
resolved.UserCertificatePath.ShouldBe(abs);
|
||||
OpcUaUserIdentityFactory.Build(resolved).TokenType.ShouldBe(Opc.Ua.UserTokenType.Certificate);
|
||||
warnings.ShouldHaveSingleItem().ShouldContain("lmxopcua#836");
|
||||
warnings[0].ShouldNotContain(abs);
|
||||
tracker.Diagnostics.ShouldContainKey(LegacyCredentialReferenceTracker.DiagnosticsKeyPrefix + "UserCertificatePath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_legacy_tracker_does_not_relax_a_relative_path_that_escapes()
|
||||
{
|
||||
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", _ => { });
|
||||
await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
Cert("../x.pfx"), null, TestContext.Current.CancellationToken, legacy: tracker, userCertificateDirectory: _dir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_certificate_auth_type_leaves_the_path_alone()
|
||||
{
|
||||
var options = new OpcUaClientDriverOptions { AuthType = OpcUaAuthType.Anonymous, UserCertificatePath = "/etc/stale.pfx" };
|
||||
(await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, null, TestContext.Current.CancellationToken, userCertificateDirectory: _dir))
|
||||
.ShouldBeSameAs(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadCertificate_refuses_an_unresolved_relative_path()
|
||||
{
|
||||
Should.Throw<InvalidOperationException>(() => OpcUaUserIdentityFactory.LoadCertificate(Cert("plant1/user.pfx")))
|
||||
.Message.ShouldContain("not been resolved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_path_is_checked_before_any_secret_is_read()
|
||||
{
|
||||
var reads = 0;
|
||||
var options = Cert("/etc/x.pfx") with { UserCertificatePassword = "secret:opcuaclient/pfx-pw" };
|
||||
await Should.ThrowAsync<CredentialReferenceException>(() => OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
options, new CountingResolver(() => reads++), TestContext.Current.CancellationToken, userCertificateDirectory: _dir));
|
||||
reads.ShouldBe(0);
|
||||
}
|
||||
|
||||
private sealed class CountingResolver(Action onRead) : ZB.MOM.WW.Secrets.Abstractions.ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(ZB.MOM.WW.Secrets.Abstractions.SecretName name, CancellationToken ct)
|
||||
{
|
||||
onRead();
|
||||
return Task.FromResult<string?>("pw");
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
-1
@@ -42,7 +42,8 @@ public sealed class BrowserSessionCredentialScopeTests
|
||||
{
|
||||
var bespoke = new RecordingBrowser(DriverTypeNames.Galaxy);
|
||||
var svc = new BrowserSessionService(
|
||||
[bespoke], new BrowseSessionRegistry(), NullLogger<BrowserSessionService>.Instance, new RecordingUniversal(), null!, null!);
|
||||
[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);
|
||||
@@ -50,6 +51,94 @@ public sealed class BrowserSessionCredentialScopeTests
|
||||
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; }
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Deployments;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Deployments;
|
||||
|
||||
/// <summary>
|
||||
/// The upgrade preflight / <c>/raw</c> banner lists the configs the deploy gate will now refuse for lmxopcua#835
|
||||
/// (another instance's secret) and #836 (absolute user-certificate path) — Draft and Deployed alike.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialReferencePreflightInstanceAndPathTests
|
||||
{
|
||||
private static DriverInstance Drv(string id, string name, string type, string config) =>
|
||||
new() { DriverInstanceId = id, ClusterId = "c", Name = name, DriverType = type, DriverConfig = config };
|
||||
|
||||
[Fact]
|
||||
public void Draft_and_deployed_cross_instance_references_are_listed()
|
||||
{
|
||||
var drivers = new[]
|
||||
{
|
||||
Drv("DRV-a", "A", DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-a/apikey"}}"""),
|
||||
Drv("DRV-b", "B", DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-a/apikey"}}"""),
|
||||
};
|
||||
var devices = new[] { new Device { DeviceId = "dev-b", DriverInstanceId = "DRV-b", Name = "gw", DeviceConfig = """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/A/ack"}}""" } };
|
||||
var deployed = new[]
|
||||
{
|
||||
new DriverInstanceSpec(Guid.NewGuid(), "DRV-a", "A", DriverTypeNames.Galaxy, true, "{}"),
|
||||
new DriverInstanceSpec(Guid.NewGuid(), "DRV-b", "B", DriverTypeNames.Galaxy, true, """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/A/apikey"}}"""),
|
||||
};
|
||||
|
||||
var findings = CredentialReferencePreflight.Scan(drivers, devices, deployed);
|
||||
|
||||
findings.Count.ShouldBe(3);
|
||||
findings.ShouldAllBe(f => f.DriverInstanceId == "DRV-b" && f.Problem.Contains("lmxopcua#835"));
|
||||
findings.Count(f => f.Source == CredentialFindingSource.Draft).ShouldBe(2);
|
||||
findings.Single(f => f.Source == CredentialFindingSource.Deployed).DriverName.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_absolute_user_certificate_path_is_listed_without_the_path()
|
||||
{
|
||||
var findings = CredentialReferencePreflight.Scan(
|
||||
[Drv("o", "Upstream", DriverTypeNames.OpcUaClient, """{"AuthType":"Certificate","UserCertificatePath":"C:\\certs\\plant.pfx"}""")],
|
||||
[],
|
||||
[]);
|
||||
|
||||
var f = findings.ShouldHaveSingleItem();
|
||||
f.Problem.ShouldContain("lmxopcua#836");
|
||||
f.Problem.ShouldNotContain("plant.pfx");
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#835 (a credential reference naming ANOTHER driver instance's secret) and #836 (the OpcUaClient
|
||||
/// user-certificate path confined to a node directory) at the SAVE path (RawTreeService): nothing reaches the
|
||||
/// config DB, through driver and device create + update.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CredentialInstanceAndPathSavePathTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static string Galaxy(string reference) =>
|
||||
"""{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"REF"}}""".Replace("REF", reference);
|
||||
|
||||
private static async Task<(RawTreeService Svc, string Db, string VictimId)> WithVictimAsync()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var victim = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "Victim", DriverTypeNames.Galaxy,
|
||||
Galaxy("secret:galaxy/victim-own/apikey"), Ct);
|
||||
victim.Ok.ShouldBeTrue(victim.Error);
|
||||
return (svc, name, victim.CreatedId!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDriver_refuses_a_reference_naming_another_instance()
|
||||
{
|
||||
var (svc, name, victimId) = await WithVictimAsync();
|
||||
|
||||
var byName = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil1", DriverTypeNames.Galaxy,
|
||||
Galaxy("secret:galaxy/Victim/apikey"), Ct);
|
||||
var byId = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil2", DriverTypeNames.Galaxy,
|
||||
Galaxy($"secret:galaxy/{victimId}/apikey"), Ct);
|
||||
|
||||
byName.Ok.ShouldBeFalse();
|
||||
byName.Error!.ShouldContain("lmxopcua#835");
|
||||
byId.Ok.ShouldBeFalse();
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
db.DriverInstances.ShouldNotContain(d => d.Name == "evil1" || d.Name == "evil2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_and_device_paths_refuse_and_keep_the_stored_config()
|
||||
{
|
||||
var (svc, name, victimId) = await WithVictimAsync();
|
||||
var evil = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "evil", DriverTypeNames.Galaxy,
|
||||
Galaxy("secret:galaxy/shared/apikey"), Ct);
|
||||
evil.Ok.ShouldBeTrue(evil.Error);
|
||||
|
||||
byte[] rv;
|
||||
string deviceId;
|
||||
byte[] deviceRv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(name))
|
||||
{
|
||||
rv = db.DriverInstances.Single(d => d.DriverInstanceId == evil.CreatedId).RowVersion;
|
||||
var device = db.Devices.Single(d => d.DriverInstanceId == evil.CreatedId);
|
||||
deviceId = device.DeviceId;
|
||||
deviceRv = device.RowVersion;
|
||||
}
|
||||
|
||||
var stolen = Galaxy($"secret:galaxy/{victimId}/apikey");
|
||||
(await svc.UpdateDriverAsync(evil.CreatedId!, "evil", stolen, null, rv, Ct)).Error.ShouldNotBeNull().ShouldContain("lmxopcua#835");
|
||||
(await svc.UpdateDeviceAsync(deviceId, "dev", stolen, true, deviceRv, Ct)).Ok.ShouldBeFalse();
|
||||
(await svc.CreateDeviceAsync(evil.CreatedId!, "dev2", stolen, Ct)).Ok.ShouldBeFalse();
|
||||
|
||||
using var check = RawTreeTestDb.CreateNamed(name);
|
||||
check.DriverInstances.Single(d => d.DriverInstanceId == evil.CreatedId).DriverConfig.ShouldContain("secret:galaxy/shared/apikey");
|
||||
check.Devices.Where(d => d.DriverInstanceId == evil.CreatedId).ShouldAllBe(d => !d.DeviceConfig.Contains(victimId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_driver_may_save_its_own_id_scoped_secret_on_update_and_on_its_devices()
|
||||
{
|
||||
var (svc, name, victimId) = await WithVictimAsync();
|
||||
byte[] rv;
|
||||
string deviceId;
|
||||
byte[] deviceRv;
|
||||
using (var db = RawTreeTestDb.CreateNamed(name))
|
||||
{
|
||||
rv = db.DriverInstances.Single(d => d.DriverInstanceId == victimId).RowVersion;
|
||||
var device = db.Devices.Single(d => d.DriverInstanceId == victimId);
|
||||
deviceId = device.DeviceId;
|
||||
deviceRv = device.RowVersion;
|
||||
}
|
||||
|
||||
var own = Galaxy($"secret:galaxy/{victimId}/apikey");
|
||||
(await svc.UpdateDriverAsync(victimId, "Victim", own, null, rv, Ct)).Ok.ShouldBeTrue();
|
||||
(await svc.UpdateDeviceAsync(deviceId, "dev", Galaxy("secret:galaxy/Victim/apikey"), true, deviceRv, Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"C:\\Windows\\System32\\config\\systemprofile\\host.pfx"}""")]
|
||||
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"/etc/ssl/private/host.pfx"}""")]
|
||||
[InlineData("""{"AuthType":"Certificate","UserCertificatePath":"../host.pfx"}""")]
|
||||
public async Task An_opcuaclient_user_certificate_path_outside_the_directory_is_never_stored(string config)
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ouc836", DriverTypeNames.OpcUaClient, config, Ct);
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error!.ShouldContain("lmxopcua#836");
|
||||
DriverConfigSaveGate.Validate(DriverTypeNames.OpcUaClient, config).ShouldNotBeNull(); // the modals' live check too
|
||||
using var db = RawTreeTestDb.CreateNamed(name);
|
||||
db.DriverInstances.ShouldNotContain(d => d.Name == "ouc836");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_relative_user_certificate_path_saves()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
(await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ouc-ok", DriverTypeNames.OpcUaClient,
|
||||
"""{"AuthType":"Certificate","UserCertificatePath":"plant1/user.pfx"}""", Ct)).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadDeviceDriverInstanceId_names_the_owning_driver()
|
||||
{
|
||||
var name = RawTreeTestDb.SeedFresh();
|
||||
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
|
||||
(await svc.LoadDeviceDriverInstanceIdAsync(RawTreeTestDb.FolderedDeviceId, Ct)).ShouldBe(RawTreeTestDb.FolderedDriverId);
|
||||
(await svc.LoadDeviceDriverInstanceIdAsync("DEV-nope", Ct)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
+77
@@ -50,6 +50,83 @@ public sealed class AdminOperationsActorCredentialScopeTests : ControlPlaneActor
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user