diff --git a/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs b/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs index c67b8a9ad089..c7106e76336a 100644 --- a/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Core/FileIOPlugin.cs @@ -113,7 +113,7 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath) Verify.NotNullOrWhiteSpace(path); canonicalPath = string.Empty; - if (path.StartsWith("\\\\", StringComparison.OrdinalIgnoreCase)) + if (IsUncOrExtendedPath(path)) { throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); } @@ -127,6 +127,13 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath) canonicalPath = PathUtilities.GetSafeFullPath(path); + // Re-check after canonicalization: resolving the path could produce a UNC + // or extended-path prefix that was not present in the original input. + if (IsUncOrExtendedPath(canonicalPath)) + { + throw new ArgumentException("Invalid file path, UNC paths are not supported.", nameof(path)); + } + if (File.Exists(canonicalPath) && File.GetAttributes(canonicalPath).HasFlag(FileAttributes.ReadOnly)) { // Most environments will throw this with OpenWrite, but running inside docker on Linux will not. @@ -162,5 +169,20 @@ private bool TryGetAllowedFilePath(string path, out string canonicalPath) return false; } + + /// + /// Determines whether the provided path is a UNC path (e.g., \\server\share or + /// //server/share) or an extended-length / device path (e.g., \\?\, \\.\). + /// Windows treats any two leading directory separators, in any combination of \ and + /// /, as such a root, so all combinations are rejected for consistency with the other + /// file plugins. Both separator characters are checked explicitly so the result does not + /// depend on the current operating system's separator. + /// + private static bool IsUncOrExtendedPath(string path) + { + return path.Length >= 2 && IsDirectorySeparator(path[0]) && IsDirectorySeparator(path[1]); + } + + private static bool IsDirectorySeparator(char c) => c is '\\' or '/'; #endregion } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs index 29d5ba6c5d1d..d28cffb2aa43 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/FileIOPluginTests.cs @@ -144,6 +144,44 @@ public async Task ItCannotReadFromDisallowedFoldersAsync() await Assert.ThrowsAsync(async () => await plugin.ReadAsync(Path.Combine("", Path.GetRandomFileName()))); } + [Theory] + [InlineData("\\\\server\\share\\file.txt")] + [InlineData("//server/share/file.txt")] + [InlineData("\\/server\\share\\file.txt")] + [InlineData("/\\server/share/file.txt")] + public async Task ItCannotReadFromUncPathsAsync(string uncPath) + { + // Arrange + var plugin = new FileIOPlugin() { AllowedFolders = [Path.GetTempPath()] }; + + // Act + var exception = await Assert.ThrowsAsync(() => plugin.ReadAsync(uncPath)); + + // Assert - the UNC guard (not another validation) must be what rejected the path + Assert.Contains("UNC paths are not supported", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("\\\\server\\share\\file.txt")] + [InlineData("//server/share/file.txt")] + [InlineData("\\/server\\share\\file.txt")] + [InlineData("/\\server/share/file.txt")] + public async Task ItCannotWriteToUncPathsAsync(string uncPath) + { + // Arrange + var plugin = new FileIOPlugin() + { + AllowedFolders = [Path.GetTempPath()], + DisableFileOverwrite = false + }; + + // Act + var exception = await Assert.ThrowsAsync(() => plugin.WriteAsync(uncPath, "hello world")); + + // Assert - the UNC guard (not another validation) must be what rejected the path + Assert.Contains("UNC paths are not supported", exception.Message, StringComparison.Ordinal); + } + [Fact] public async Task ItCannotReadThroughSymlinkOutsideAllowedFoldersAsync() {