diff --git a/app/desktop/Nori.Core.Tests/PluginAssetServerReviewTests.cs b/app/desktop/Nori.Core.Tests/PluginAssetServerReviewTests.cs new file mode 100644 index 00000000..f47b58b2 --- /dev/null +++ b/app/desktop/Nori.Core.Tests/PluginAssetServerReviewTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using Nori.Core.Assets; + +namespace Nori.Core.Tests; + +public sealed class PluginAssetServerReviewTests +{ + [Fact] + public async Task 超过64位但符合Manifest规范的插件ID可以读取公开资源() + { + string root = Path.Combine(Path.GetTempPath(), $"nori-plugin-asset-review-{Guid.NewGuid():N}"); + string appRoot = Path.Combine(root, "app"); + string resourcesRoot = Path.Combine(root, "resources"); + string pluginId = "io." + new string('a', 70); + string pluginRoot = Path.Combine(root, "plugins", pluginId); + Directory.CreateDirectory(appRoot); + Directory.CreateDirectory(resourcesRoot); + Directory.CreateDirectory(Path.Combine(pluginRoot, "web")); + await File.WriteAllTextAsync(Path.Combine(appRoot, "index.html"), "app"); + await File.WriteAllTextAsync(Path.Combine(pluginRoot, "web", "index.html"), "long-plugin-id"); + + try + { + await using AssetServer server = await AssetServer.StartAsync(new AssetServerOptions + { + AppRoot = appRoot, + ResourcesRoot = resourcesRoot, + PluginRootResolver = id => string.Equals(id, pluginId, StringComparison.Ordinal) ? pluginRoot : null, + }); + using HttpClient client = new(); + + HttpResponseMessage response = await client.GetAsync(new Uri(server.PluginAssetUrl(pluginId, "web/index.html"))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("long-plugin-id", await response.Content.ReadAsStringAsync()); + } + finally + { + try { if (Directory.Exists(root)) Directory.Delete(root, true); } catch { } + } + } +} diff --git a/app/desktop/Nori.Core/Assets/AssetServer.cs b/app/desktop/Nori.Core/Assets/AssetServer.cs index 426bfdb4..5adfd1d9 100644 --- a/app/desktop/Nori.Core/Assets/AssetServer.cs +++ b/app/desktop/Nori.Core/Assets/AssetServer.cs @@ -133,7 +133,7 @@ public static async Task StartAsync(AssetServerOptions options, Can string[] parts = relative.Split('/', 2, StringSplitOptions.RemoveEmptyEntries); string? pluginId = parts.Length == 2 ? AssetPath.PercentDecode(parts[0]) : null; string? assetPath = parts.Length == 2 ? AssetPath.PercentDecode(parts[1]) : null; - if (pluginId is null || assetPath is null || pluginId.Length is 0 or > 64 || !IsSafePluginId(pluginId) || !IsPublicPluginAsset(assetPath)) + if (pluginId is null || assetPath is null || pluginId.Length is 0 or > 128 || !IsSafePluginId(pluginId) || !IsPublicPluginAsset(assetPath)) { await Fail(context); return; diff --git a/app/desktop/Nori.Desktop.Tests/PluginHostTests.cs b/app/desktop/Nori.Desktop.Tests/PluginHostTests.cs index 43276cff..ca21fd30 100644 --- a/app/desktop/Nori.Desktop.Tests/PluginHostTests.cs +++ b/app/desktop/Nori.Desktop.Tests/PluginHostTests.cs @@ -488,20 +488,27 @@ private sealed class FakePluginBridgeSource(string pluginId, string windowId) : public bool Closed { get; private set; } private readonly Dictionary> _pending = new(); + private readonly Dictionary _completed = new(); private readonly Lock _lock = new(); public void PostResult(long id, object? value, string? error) { + FakeResult result = new() + { + Kind = error == null ? "resolve" : "reject", + Value = value, + Error = error, + }; + lock (_lock) { - if (_pending.TryGetValue(id, out TaskCompletionSource? tcs)) + if (_pending.Remove(id, out TaskCompletionSource? tcs)) + { + tcs.TrySetResult(result); + } + else { - tcs.TrySetResult(new FakeResult - { - Kind = error == null ? "resolve" : "reject", - Value = value, - Error = error, - }); + _completed[id] = result; } } } @@ -521,6 +528,10 @@ public Task WaitForResultAsync(long id, int timeoutMs = 2000) TaskCompletionSource tcs; lock (_lock) { + if (_completed.Remove(id, out FakeResult? completed)) + { + return Task.FromResult(completed); + } if (!_pending.TryGetValue(id, out tcs!)) { tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs b/app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs index a33d9033..531138b8 100644 --- a/app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs +++ b/app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs @@ -163,7 +163,7 @@ public async Task CreateWindowAsync( /// public IReadOnlyList GetWindowsForPlugin(string pluginId) { - if (!IsValidId(pluginId)) return []; + if (!IsValidPluginId(pluginId)) return []; return _windows.Values.Where(w => string.Equals(w.PluginId, pluginId, StringComparison.Ordinal)).ToArray(); } diff --git a/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs b/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs new file mode 100644 index 00000000..aedd3e52 --- /dev/null +++ b/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs @@ -0,0 +1,130 @@ +using System.IO.Compression; +using System.Text.Json.Nodes; +using Nori.Plugin.Abstractions; +using Nori.Plugin.Runtime; + +namespace Nori.Plugin.Runtime.Tests; + +public sealed class PluginRuntimeReviewTests +{ + [Fact] + public async Task 禁用尚未激活的插件会保持Disabled状态() + { + string root = CreateTemp(); + try + { + string package = CreateTestPackage(root, "disabled.plugin"); + PluginManager manager = new(new PluginRuntimeOptions + { + PluginsDirectory = Path.Combine(root, "plugins"), + DataDirectory = Path.Combine(root, "plugin-data"), + KnownCapabilityIds = [], + }); + manager.Install(package); + + await manager.DisableAsync("disabled.plugin"); + + Assert.Equal(PluginLifecycleState.Disabled, Assert.Single(manager.Plugins).State); + await manager.DisposeAsync(); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public void 安装目录中的Contract副本会在加载前被拒绝() + { + string root = CreateTemp(); + try + { + string lib = Path.Combine(root, "lib"); + Directory.CreateDirectory(lib); + File.Copy(typeof(INoriPlugin).Assembly.Location, Path.Combine(lib, "Nori.Plugin.Abstractions.dll")); + + PluginException exception = Assert.Throws(() => PluginLoadContext.EnsureReferencesAllowed(root)); + Assert.Equal(PluginErrorCodes.ContractAssemblyDenied, exception.Code); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public void Windows原生Dll不会被误判为损坏的托管程序集() + { + if (!OperatingSystem.IsWindows()) return; + + string root = CreateTemp(); + try + { + string runtime = Path.Combine(root, "runtimes", "win-x64", "native"); + Directory.CreateDirectory(runtime); + string systemDll = Path.Combine(Environment.SystemDirectory, "kernel32.dll"); + Assert.True(File.Exists(systemDll)); + File.Copy(systemDll, Path.Combine(runtime, "plugin-native.dll")); + + PluginLoadContext.EnsureReferencesAllowed(root); + } + finally { DeleteDirectory(root); } + } + + [Fact] + public async Task Unix允许系统级符号链接祖先但仍拒绝插件根本身是链接() + { + if (OperatingSystem.IsWindows()) return; + + string root = CreateTemp(); + try + { + string realParent = Path.Combine(root, "real-parent"); + Directory.CreateDirectory(realParent); + string linkedParent = Path.Combine(root, "linked-parent"); + Directory.CreateSymbolicLink(linkedParent, realParent); + + PluginPackageInstaller installer = new(Path.Combine(linkedParent, "plugins")); + Assert.True(Directory.Exists(installer.RootDirectory)); + + JsonPluginStorage storage = new(Path.Combine(linkedParent, "plugin-data", "demo.plugin")); + await storage.SetAsync("state", new JsonObject { ["ok"] = true }); + Assert.True((await storage.GetAsync("state"))!["ok"]!.GetValue()); + + string realPluginRoot = Path.Combine(root, "real-plugin-root"); + Directory.CreateDirectory(realPluginRoot); + string linkedPluginRoot = Path.Combine(root, "linked-plugin-root"); + Directory.CreateSymbolicLink(linkedPluginRoot, realPluginRoot); + + PluginException exception = Assert.Throws(() => new PluginPackageInstaller(linkedPluginRoot)); + Assert.Equal(PluginErrorCodes.PackagePathDenied, exception.Code); + } + finally { DeleteDirectory(root); } + } + + private static string CreateTestPackage(string root, string id) + { + string package = Path.Combine(root, $"{id}.noripack"); + string assembly = typeof(Nori.Plugin.TestPlugin.TestPlugin).Assembly.Location; + using FileStream file = File.Create(package); + using ZipArchive archive = new(file, ZipArchiveMode.Create); + WriteEntry(archive, "manifest.json", $"{{\"schemaVersion\":1,\"id\":\"{id}\",\"name\":\"Demo\",\"description\":\"Demo plugin\",\"version\":\"1.0.0\",\"authors\":[{{\"name\":\"Nori\"}}],\"apiVersion\":\"1.0\",\"minHostVersion\":\"1.0.0\",\"runtime\":{{\"kind\":\"dotnet\",\"assembly\":\"lib/Nori.Plugin.TestPlugin.dll\",\"entryType\":\"Nori.Plugin.TestPlugin.TestPlugin\"}},\"capabilities\":[],\"optionalCapabilities\":[],\"platforms\":[],\"dependencies\":[]}}"); + ZipArchiveEntry assemblyEntry = archive.CreateEntry("lib/Nori.Plugin.TestPlugin.dll"); + using Stream target = assemblyEntry.Open(); + using FileStream source = File.OpenRead(assembly); + source.CopyTo(target); + return package; + } + + private static void WriteEntry(ZipArchive archive, string name, string content) + { + using StreamWriter writer = new(archive.CreateEntry(name).Open()); + writer.Write(content); + } + + private static string CreateTemp() + { + string path = Path.Combine(Path.GetTempPath(), "nori-plugin-review-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteDirectory(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, true); } catch { } + } +} diff --git a/app/desktop/Nori.Plugin.Runtime/PluginContextServices.cs b/app/desktop/Nori.Plugin.Runtime/PluginContextServices.cs index 51a97ac9..a98e74c9 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginContextServices.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginContextServices.cs @@ -260,19 +260,9 @@ private static void ValidateKey(string key) throw new PluginException(PluginErrorCodes.StorageFailed, "插件存储键无效"); } - private static void EnsureNoReparsePoints(string path) - { - string fullPath = Path.GetFullPath(path); - string root = Path.GetPathRoot(fullPath) ?? fullPath; - string relative = Path.GetRelativePath(root, fullPath); - string current = root; - foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - current = Path.Combine(current, segment); - if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new PluginException(PluginErrorCodes.StorageFailed, "插件存储目录包含符号链接"); - } - } + /// 拒绝插件自己的存储目录是链接,但允许宿主运行于系统级 symlink 祖先之下。 + private static void EnsureNoReparsePoints(string path) => + PluginPathSafety.EnsureNoReparsePoint(path, PluginErrorCodes.StorageFailed, "插件存储目录包含符号链接"); } /// 插件包公开资源读取器。 diff --git a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs index 5ce81f5d..cd1f19e7 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs @@ -58,14 +58,17 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) : IntPtr.Zero; } - /// 预扫描插件目录,拒绝宿主内部引用与重复 contract DLL。 + /// 预扫描插件目录,拒绝宿主内部引用、重复 contract DLL 与插件树内链接。 public static void EnsureReferencesAllowed(string pluginDirectory) { if (!Directory.Exists(pluginDirectory)) throw new PluginException(PluginErrorCodes.InvalidPackage, "插件目录不存在"); - EnsureNoReparsePoints(pluginDirectory); - foreach (string path in Directory.EnumerateFiles(pluginDirectory, "*.dll", SearchOption.AllDirectories)) + IReadOnlyList dlls = PluginPathSafety.EnumerateDllFilesWithoutReparsePoints( + pluginDirectory, + PluginErrorCodes.PackagePathDenied, + "插件目录包含符号链接"); + foreach (string path in dlls) { - if (ContractAssemblyNames.Contains(Path.GetFileName(path))) + if (ContractAssemblyNames.Contains(Path.GetFileNameWithoutExtension(path))) throw new PluginException(PluginErrorCodes.ContractAssemblyDenied, "插件包不得携带宿主 contract 程序集"); EnsureAssemblyReferencesAllowed(path); } @@ -77,7 +80,9 @@ private static void EnsureAssemblyReferencesAllowed(string assemblyPath) { using FileStream stream = File.OpenRead(assemblyPath); using PEReader reader = new(stream); - if (!reader.HasMetadata) throw new BadImageFormatException(); + // runtimes/win-* 中的原生 DLL 是合法插件依赖。只有托管程序集才有 + // AssemblyReference 元数据可供宿主做禁止引用扫描。 + if (!reader.HasMetadata) return; MetadataReader metadata = reader.GetMetadataReader(); foreach (AssemblyReferenceHandle handle in metadata.AssemblyReferences) { @@ -95,20 +100,6 @@ private static void EnsureAssemblyReferencesAllowed(string assemblyPath) } } - private static void EnsureNoReparsePoints(string path) - { - string fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - string root = Path.GetPathRoot(fullPath) ?? fullPath; - string relative = Path.GetRelativePath(root, fullPath); - string current = root; - foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - current = Path.Combine(current, segment); - if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new PluginException(PluginErrorCodes.PackagePathDenied, "插件目录包含符号链接"); - } - } - private static bool IsForbidden(string name) => name.Equals("Nori.Core", StringComparison.OrdinalIgnoreCase) || name.Equals("Nori.Desktop", StringComparison.OrdinalIgnoreCase) || diff --git a/app/desktop/Nori.Plugin.Runtime/PluginManager.cs b/app/desktop/Nori.Plugin.Runtime/PluginManager.cs index 90a92518..4b29f29b 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginManager.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginManager.cs @@ -472,11 +472,7 @@ private void UnloadContext(PluginHandle handle) { PluginLoadContext? context = handle.LoadContext; handle.LoadContext = null; - if (context is null) - { - if (handle.State != PluginLifecycleState.Failed) handle.State = PluginLifecycleState.Installed; - return; - } + if (context is null) return; WeakReference weak; try { weak = UnloadAndTrack(context); } catch (Exception exception) @@ -499,10 +495,6 @@ private void UnloadContext(PluginHandle handle) handle.State = PluginLifecycleState.PendingRestart; handle.ErrorCode = PluginErrorCodes.UnloadPendingRestart; } - else if (handle.State != PluginLifecycleState.Failed) - { - handle.State = PluginLifecycleState.Installed; - } } [MethodImpl(MethodImplOptions.NoInlining)] diff --git a/app/desktop/Nori.Plugin.Runtime/PluginPackageInstaller.cs b/app/desktop/Nori.Plugin.Runtime/PluginPackageInstaller.cs index a76b4567..fd5bb3d2 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginPackageInstaller.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginPackageInstaller.cs @@ -34,6 +34,8 @@ public PluginPackageInstaller(string root) Directory.CreateDirectory(_stagingRoot); Directory.CreateDirectory(_inboxRoot); EnsureNoReparsePoints(_root); + EnsureNoReparsePoints(_stagingRoot); + EnsureNoReparsePoints(_inboxRoot); } public string RootDirectory => _root; @@ -200,8 +202,8 @@ private static string ValidatePackagePath(string packagePath) string fullPath = Path.GetFullPath(packagePath); if (!fullPath.EndsWith(PluginPackageInstaller.PackageExtension, StringComparison.OrdinalIgnoreCase)) throw new PluginException(PluginErrorCodes.InvalidPackage, "插件包扩展名必须为 .noripack"); - EnsureNoReparsePoints(Path.GetDirectoryName(fullPath) ?? fullPath); if (!File.Exists(fullPath)) throw new PluginException(PluginErrorCodes.InvalidPackage, "插件包不存在"); + EnsureNoReparsePoints(fullPath); return fullPath; } @@ -228,22 +230,12 @@ private static void WriteCurrentPointer(string pointerPath, string version) } } - private static void EnsureNoReparsePoints(string path) - { - string fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); - string root = Path.GetPathRoot(fullPath) ?? fullPath; - string relative = Path.GetRelativePath(root, fullPath); - string current = root; - foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - current = Path.Combine(current, segment); - if (File.Exists(current) || Directory.Exists(current)) - { - if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new PluginException(PluginErrorCodes.PackagePathDenied, "插件包路径包含符号链接"); - } - } - } + /// + /// 只检查宿主管理的目标节点本身,不检查其系统级祖先目录。 + /// 插件目录树内部的链接由 PluginLoadContext 的锚定扫描负责拒绝。 + /// + private static void EnsureNoReparsePoints(string path) => + PluginPathSafety.EnsureNoReparsePoint(path, PluginErrorCodes.PackagePathDenied, "插件包路径包含符号链接"); private static void TryDelete(string path) { diff --git a/app/desktop/Nori.Plugin.Runtime/PluginPathSafety.cs b/app/desktop/Nori.Plugin.Runtime/PluginPathSafety.cs new file mode 100644 index 00000000..8aeca3ad --- /dev/null +++ b/app/desktop/Nori.Plugin.Runtime/PluginPathSafety.cs @@ -0,0 +1,137 @@ +using Nori.Plugin.Abstractions; + +namespace Nori.Plugin.Runtime; + +/// +/// 插件运行时的文件系统边界检查。 +/// +/// 安全检查从宿主管理的插件/数据根开始,而不是从文件系统根开始。 +/// 这样既拒绝插件根及其内部的符号链接、junction/reparse point, +/// 也允许宿主运行在 macOS /var 等系统级别名或 CI 工作目录链接之下。 +/// +internal static class PluginPathSafety +{ + public static void EnsureNoReparsePoint(string path, string errorCode, string message) + { + string fullPath = FullPath(path); + FileAttributes attributes; + try + { + attributes = File.GetAttributes(fullPath); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new PluginException(errorCode, message, exception); + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + throw new PluginException(errorCode, message); + + try + { + FileSystemInfo info = (attributes & FileAttributes.Directory) != 0 + ? new DirectoryInfo(fullPath) + : new FileInfo(fullPath); + if (info.LinkTarget is not null) + throw new PluginException(errorCode, message); + } + catch (PluginException) + { + throw; + } + catch (FileNotFoundException) + { + } + catch (DirectoryNotFoundException) + { + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new PluginException(errorCode, message, exception); + } + } + + public static void EnsureNoReparsePoints(string trustedRoot, string path, string errorCode, string message) + { + string root = FullPath(trustedRoot); + string target = FullPath(path); + if (!IsSameOrWithin(root, target)) + throw new PluginException(errorCode, message); + + EnsureNoReparsePoint(root, errorCode, message); + string relative = Path.GetRelativePath(root, target); + if (relative == ".") return; + + string current = root; + foreach (string segment in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + { + if (segment is "." or "..") throw new PluginException(errorCode, message); + current = Path.Combine(current, segment); + EnsureNoReparsePoint(current, errorCode, message); + } + } + + public static IReadOnlyList EnumerateDllFilesWithoutReparsePoints(string root, string errorCode, string message) + { + string canonicalRoot = FullPath(root); + EnsureNoReparsePoint(canonicalRoot, errorCode, message); + + List dlls = []; + Stack directories = new(); + directories.Push(canonicalRoot); + while (directories.Count > 0) + { + string directory = directories.Pop(); + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + EnsureNoReparsePoint(entry, errorCode, message); + FileAttributes attributes; + try { attributes = File.GetAttributes(entry); } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new PluginException(errorCode, message, exception); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + directories.Push(entry); + } + else if (string.Equals(Path.GetExtension(entry), ".dll", StringComparison.OrdinalIgnoreCase)) + { + dlls.Add(entry); + } + } + } + return dlls; + } + + private static string FullPath(string path) + { + string fullPath = Path.GetFullPath(path); + string? root = Path.GetPathRoot(fullPath); + return root is not null && fullPath.Equals(root, Comparison) + ? root + : Path.TrimEndingDirectorySeparator(fullPath); + } + + private static bool IsSameOrWithin(string root, string path) + { + if (path.Equals(root, Comparison)) return true; + string prefix = root.EndsWith(Path.DirectorySeparatorChar) + ? root + : root + Path.DirectorySeparatorChar; + return path.StartsWith(prefix, Comparison); + } + + private static StringComparison Comparison => OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; +}