Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialFilePaths.cs
T
Joseph Doherty 271b96a644 fix(security): #835/#836 review — unsaved endpoints never borrow the owner's secrets; audit credential use; path rule follows AuthType
I-1: Test Connect / browse configs may carry unsaved edits (the device modal merges
the stored DriverConfig with the typed device config). The server
(DriverInstanceIdentities.EffectiveOwnerAsync, used by AdminOperationsActor and
BrowserSessionService) honours the named owner only when every endpoint value
(new DriverCredentialRules.EndpointFields: Galaxy Endpoint, OpcUaClient
EndpointUrl/EndpointUrls/DiscoveryUrl, Heidenhain Host/Port) is one that driver
stores; otherwise the owner is null and its instance-named secrets are refused.
Every Test Connect / browse whose config carries an in-scope secret:/env: reference
is recorded (CredentialUseAudit: IAuditWriter event category CredentialUse + a
structured Information line: user, driver, endpoint hosts, outcome).

L-1: the UserCertificatePath rule is skipped only in an object that explicitly sets
AuthType to non-Certificate (string or ordinal); the deploy gate also checks the
runtime-merged driver+device config so the switch cannot be split from the path; the
form no longer persists the hidden field.
L-2: a ConfigDb fault in the browse check is a refusal; RawBrowseModal catches open
failures into its error message.
L-4: a relative UserCertificateDirectory resolves against AppContext.BaseDirectory.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 04:12:40 -05:00

153 lines
8.6 KiB
C#

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>
/// <param name="ActiveWhenKey">
/// Optional sibling key that switches the field on (OpcUaClient <c>AuthType</c>): the field is checked UNLESS the same
/// JSON object sets this key to something other than <paramref name="ActiveWhenValue"/> /
/// <paramref name="ActiveWhenOrdinal"/>. An absent key keeps the field checked (conservative — the driver's merge may
/// supply the switch from another config), so only an EXPLICITLY inactive object is exempt.
/// </param>
/// <param name="ActiveWhenValue">The string value of <paramref name="ActiveWhenKey"/> that activates the field.</param>
/// <param name="ActiveWhenOrdinal">The numeric (enum ordinal) value that activates the field.</param>
public sealed record CredentialPathField(
string Name, string DirectoryDescription, string? ActiveWhenKey = null, string? ActiveWhenValue = null, int? ActiveWhenOrdinal = null)
{
/// <summary>Whether the field is in force in <paramref name="owner"/> (the JSON object holding it).</summary>
/// <param name="owner">The object.</param>
/// <returns>False only when the object explicitly switches the field off.</returns>
public bool IsActiveIn(System.Text.Json.JsonElement owner)
{
if (ActiveWhenKey is null || owner.ValueKind != System.Text.Json.JsonValueKind.Object) return true;
foreach (var p in owner.EnumerateObject())
{
if (!string.Equals(p.Name, ActiveWhenKey, StringComparison.OrdinalIgnoreCase)) continue;
return p.Value.ValueKind switch
{
System.Text.Json.JsonValueKind.Null => true,
System.Text.Json.JsonValueKind.String => string.Equals(p.Value.GetString(), ActiveWhenValue, StringComparison.OrdinalIgnoreCase),
System.Text.Json.JsonValueKind.Number => !p.Value.TryGetInt32(out var n) || n == ActiveWhenOrdinal,
_ => true,
};
}
return true;
}
}
/// <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;
}
}