using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; /// /// lmxopcua#836: a key-material file field (OpcUaClient UserCertificatePath) is a relative path confined to a /// node directory — syntactically at the admin seams, lexically + through links at load time. /// [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(() => 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(() => 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(() => 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) { } } } }