63331b0416
#835: inside one driver namespace, a secret:<ns>/<X>/... reference whose first segment names ANOTHER existing same-type driver instance (id or name, case-insensitive; another-instance match beats an own-name match, so renaming one's driver after a victim's unlocks nothing) is refused at save (DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance, Heidenhain included), Test Connect (TestDriverConnect.DriverInstanceId, ConfigDb read off the mailbox) and live browse (BrowserSessionService, fail-closed without a ConfigDb). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems + DriverInstanceIdentities. Shared names and the driver's own id stay valid; env: is not instance-scoped. Running drivers are unchanged (warn-only window: the deploy gate and the upgrade preflight / /raw banner list them). #836: OpcUaClient UserCertificatePath is a relative path inside Drivers:OpcUaClient:UserCertificateDirectory (default <LocalAppData>/OtOpcUa/pki/user-certificates). Syntactic rule (CredentialFilePaths, a DriverCredentialPolicy path field) at every #815 seam; at load time ResolveSecretRefsAsync joins, canonicalises and requires lexical AND link-resolved containment before the PFX password is resolved, and LoadCertificate refuses an unresolved path. A running driver's pre-#836 absolute path keeps loading with a rate-limited warning (LegacyCredentialReferenceTracker.ReportPath). Co-Authored-By: Claude <noreply@anthropic.com>
167 lines
6.5 KiB
C#
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) { }
|
|
}
|
|
}
|
|
}
|