diff --git a/src/Opc.Ua.Di.Server/SoftwareUpdate/FileSystemPackageStore.cs b/src/Opc.Ua.Di.Server/SoftwareUpdate/FileSystemPackageStore.cs index 2892e05df6..87b26e46c0 100644 --- a/src/Opc.Ua.Di.Server/SoftwareUpdate/FileSystemPackageStore.cs +++ b/src/Opc.Ua.Di.Server/SoftwareUpdate/FileSystemPackageStore.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; @@ -74,6 +75,7 @@ public sealed class FileSystemPackageStore : ISoftwarePackageStore private readonly IFileSystemProvider m_provider; private readonly string m_rootPath; + private readonly string m_rootPrefix; private readonly TimeProvider m_timeProvider; #if NET8_0_OR_GREATER @@ -109,7 +111,10 @@ public FileSystemPackageStore( throw new ArgumentException( "Root path must be a non-empty string.", nameof(rootPath)); } - m_rootPath = rootPath; + m_rootPath = CanonicalizeProviderPath(rootPath, nameof(rootPath)); + m_rootPrefix = m_rootPath == "/" + ? m_rootPath + : m_rootPath + "/"; m_timeProvider = timeProvider ?? TimeProvider.System; } @@ -299,9 +304,25 @@ await JsonSerializer private string BuildPath(string packageId, string? leaf = null) { - string root = m_rootPath.TrimEnd('/'); - string path = $"{root}/{packageId}"; - return leaf == null ? path : $"{path}/{leaf}"; + ValidateId(packageId); + + string path = m_rootPath == "/" + ? $"/{packageId}" + : $"{m_rootPath}/{packageId}"; + if (leaf != null) + { + path += $"/{leaf}"; + } + + string canonicalPath = CanonicalizeProviderPath(path, nameof(path)); + if (canonicalPath.Length <= m_rootPath.Length || + !canonicalPath.StartsWith(m_rootPrefix, StringComparison.Ordinal)) + { + throw new UnauthorizedAccessException( + "The package path escapes the configured package-store root."); + } + + return canonicalPath; } private static void ValidateId(string packageId) @@ -316,6 +337,54 @@ private static void ValidateId(string packageId) throw new ArgumentException( "Package id must not contain path separators.", nameof(packageId)); } + if (packageId is "." or "..") + { + throw new ArgumentException( + "Package id must not be a dot segment.", nameof(packageId)); + } + } + + private static string CanonicalizeProviderPath(string path, string parameterName) + { + var canonicalPath = new StringBuilder(path.Length + 1); + canonicalPath.Append('/'); + int index = 0; + while (index < path.Length) + { + while (index < path.Length && path[index] is '/' or '\\') + { + index++; + } + + int segmentStart = index; + while (index < path.Length && path[index] is not ('/' or '\\')) + { + index++; + } + + int segmentLength = index - segmentStart; + if (segmentLength == 0) + { + break; + } + if ((segmentLength == 1 && path[segmentStart] == '.') || + (segmentLength == 2 && + path[segmentStart] == '.' && + path[segmentStart + 1] == '.')) + { + throw new ArgumentException( + "Provider-relative paths must not contain dot segments.", + parameterName); + } + + if (canonicalPath.Length > 1) + { + canonicalPath.Append('/'); + } + canonicalPath.Append(path, segmentStart, segmentLength); + } + + return canonicalPath.ToString(); } } diff --git a/tests/Opc.Ua.Di.Tests/PackageStoreTests.cs b/tests/Opc.Ua.Di.Tests/PackageStoreTests.cs index 1292749da6..d31cdf90e0 100644 --- a/tests/Opc.Ua.Di.Tests/PackageStoreTests.cs +++ b/tests/Opc.Ua.Di.Tests/PackageStoreTests.cs @@ -258,6 +258,99 @@ public void FileSystemStoreRejectsIdWithPathSeparator() } } + [TestCase(".")] + [TestCase("..")] + public void FileSystemStoreRejectsDotSegmentId(string packageId) + { + string tempRoot = Path.Combine( + Path.GetTempPath(), + "di-pkg-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + try + { + var provider = new PhysicalFileSystemProvider( + rootDirectory: tempRoot, + mountName: "Packages", + isWritable: true); + var store = new FileSystemPackageStore(provider, rootPath: "/Pkgs"); + + Assert.ThrowsAsync( + async () => await store.GetAsync(packageId).ConfigureAwait(false)); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [TestCase("/Pkgs/./Nested")] + [TestCase("/Pkgs/../Outside")] + [TestCase("\\Pkgs\\..\\Outside")] + public void FileSystemStoreRejectsRootWithDotSegment(string rootPath) + { + string tempRoot = Path.Combine( + Path.GetTempPath(), + "di-pkg-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + try + { + var provider = new PhysicalFileSystemProvider( + rootDirectory: tempRoot, + mountName: "Packages", + isWritable: true); + + Assert.That( + () => new FileSystemPackageStore(provider, rootPath), + Throws.TypeOf()); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + + [Test] + public async Task FileSystemStoreCanonicalizesProviderRelativeRootAsync() + { + string tempRoot = Path.Combine( + Path.GetTempPath(), + "di-pkg-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + try + { + var provider = new PhysicalFileSystemProvider( + rootDirectory: tempRoot, + mountName: "Packages", + isWritable: true); + var store = new FileSystemPackageStore( + provider, + rootPath: "\\Pkgs\\\\Nested\\"); + using var payload = new MemoryStream([1, 2, 3]); + + await store.AddAsync( + NewMetadata("firmware"), + payload).ConfigureAwait(false); + + string packageRoot = Path.Combine(tempRoot, "Pkgs", "Nested", "firmware"); + Assert.That(File.Exists(Path.Combine(packageRoot, "payload.bin")), Is.True); + Assert.That(File.Exists(Path.Combine(packageRoot, "metadata.json")), Is.True); + Assert.That(File.Exists(Path.Combine(tempRoot, "payload.bin")), Is.False); + } + finally + { + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, recursive: true); + } + } + } + private static SoftwarePackage NewMetadata(string id) { return new(