From fdcf462541abcb4273eb2e6eea7f02f5f4172a03 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:43:04 +0800 Subject: [PATCH 01/12] fix(plugin): allow native runtime libraries and detect contract copies --- app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs index 5ce81f5d..66817f9c 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs @@ -65,7 +65,7 @@ public static void EnsureReferencesAllowed(string pluginDirectory) EnsureNoReparsePoints(pluginDirectory); foreach (string path in Directory.EnumerateFiles(pluginDirectory, "*.dll", SearchOption.AllDirectories)) { - if (ContractAssemblyNames.Contains(Path.GetFileName(path))) + if (ContractAssemblyNames.Contains(Path.GetFileNameWithoutExtension(path))) throw new PluginException(PluginErrorCodes.ContractAssemblyDenied, "插件包不得携带宿主 contract 程序集"); EnsureAssemblyReferencesAllowed(path); } @@ -77,7 +77,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) { From b1faa6b3bfd90c3395cd9426784466d64027c97c Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:43:27 +0800 Subject: [PATCH 02/12] fix(plugin): honor full manifest plugin id length in window host --- app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); } From e9f512a855ed9f57983bbc626b366acf272f7f83 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:44:32 +0800 Subject: [PATCH 03/12] fix(plugin): preserve lifecycle state after unloading ALC --- app/desktop/Nori.Plugin.Runtime/PluginManager.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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)] From d372a88436c44149446594fe3f5c0f3191d1e157 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:46:20 +0800 Subject: [PATCH 04/12] fix(plugin): align asset routing with manifest id length --- app/desktop/Nori.Core/Assets/AssetServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From aed74fd6cb635def0783aa0a30a4f727e4ff9eba Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:46:47 +0800 Subject: [PATCH 05/12] test(plugin): cover disable state and assembly scanning regressions --- .../PluginRuntimeReviewTests.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs 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..3406a920 --- /dev/null +++ b/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs @@ -0,0 +1,98 @@ +using System.IO.Compression; +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); } + } + + 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 { } + } +} From 9054de7e2f676165394fec69d0a720b58666d5d2 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:47:01 +0800 Subject: [PATCH 06/12] test(plugin): cover full manifest id range in asset server --- .../PluginAssetServerReviewTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 app/desktop/Nori.Core.Tests/PluginAssetServerReviewTests.cs 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 { } + } + } +} From 828ad6df5175d9f953dbe6f067608757b670d0e6 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:52:57 +0800 Subject: [PATCH 07/12] test(plugin): remove bridge response race --- .../Nori.Desktop.Tests/PluginHostTests.cs | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) 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); From ddd92b9a6116b74beea2721f73fdcf9ddf8e329d Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:59:20 +0800 Subject: [PATCH 08/12] fix(plugin): anchor reparse checks to plugin roots --- .../Nori.Plugin.Runtime/PluginPathSafety.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 app/desktop/Nori.Plugin.Runtime/PluginPathSafety.cs 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; +} From 68d4bc78aaacc46ee6870924b2a3ddb0fa25d168 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:59:38 +0800 Subject: [PATCH 09/12] fix(plugin): use anchored path checks in load context --- .../Nori.Plugin.Runtime/PluginLoadContext.cs | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs index 66817f9c..cd1f19e7 100644 --- a/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs +++ b/app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs @@ -58,12 +58,15 @@ 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.GetFileNameWithoutExtension(path))) throw new PluginException(PluginErrorCodes.ContractAssemblyDenied, "插件包不得携带宿主 contract 程序集"); @@ -97,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) || From 642d2ffd7dac3e59060f080b30c2ab909c30b469 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:00:20 +0800 Subject: [PATCH 10/12] fix(plugin): allow system symlink ancestors for package paths --- .../PluginPackageInstaller.cs | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) 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) { From e546a67184917bb58a8b9d013c72a60a2e4353f0 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:00:54 +0800 Subject: [PATCH 11/12] fix(plugin): allow system symlink ancestors for storage paths --- .../Nori.Plugin.Runtime/PluginContextServices.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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, "插件存储目录包含符号链接"); } /// 插件包公开资源读取器。 From 4703567652050498fd64a2e6060f74af4ce6b6a5 Mon Sep 17 00:00:00 2001 From: "MF." <128943330+MF-Dust@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:01:31 +0800 Subject: [PATCH 12/12] test(plugin): cover symlink ancestors without relaxing plugin roots --- .../PluginRuntimeReviewTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs b/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs index 3406a920..aedd3e52 100644 --- a/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs +++ b/app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Text.Json.Nodes; using Nori.Plugin.Abstractions; using Nori.Plugin.Runtime; @@ -64,6 +65,37 @@ public void Windows原生Dll不会被误判为损坏的托管程序集() 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");