Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialFilePathsTests.cs
T
Joseph Doherty 63331b0416 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>
2026-11-11 03:52:48 -05:00

167 lines
6.5 KiB
C#

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) { }
}
}
}