Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions app/desktop/Nori.Core.Tests/PluginAssetServerReviewTests.cs
Original file line number Diff line number Diff line change
@@ -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 { }
}
}
}
2 changes: 1 addition & 1 deletion app/desktop/Nori.Core/Assets/AssetServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public static async Task<AssetServer> 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;
Expand Down
25 changes: 18 additions & 7 deletions app/desktop/Nori.Desktop.Tests/PluginHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -488,20 +488,27 @@ private sealed class FakePluginBridgeSource(string pluginId, string windowId) :
public bool Closed { get; private set; }

private readonly Dictionary<long, TaskCompletionSource<FakeResult>> _pending = new();
private readonly Dictionary<long, FakeResult> _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<FakeResult>? tcs))
if (_pending.Remove(id, out TaskCompletionSource<FakeResult>? tcs))
{
tcs.TrySetResult(result);
}
else
{
tcs.TrySetResult(new FakeResult
{
Kind = error == null ? "resolve" : "reject",
Value = value,
Error = error,
});
_completed[id] = result;
}
}
}
Expand All @@ -521,6 +528,10 @@ public Task<FakeResult> WaitForResultAsync(long id, int timeoutMs = 2000)
TaskCompletionSource<FakeResult> tcs;
lock (_lock)
{
if (_completed.Remove(id, out FakeResult? completed))
{
return Task.FromResult(completed);
}
if (!_pending.TryGetValue(id, out tcs!))
{
tcs = new TaskCompletionSource<FakeResult>(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down
2 changes: 1 addition & 1 deletion app/desktop/Nori.Desktop/Plugins/PluginWindowHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ public async Task<IPluginWebViewWindow> CreateWindowAsync(
/// </summary>
public IReadOnlyList<PluginWebViewWindow> GetWindowsForPlugin(string pluginId)
{
if (!IsValidId(pluginId)) return [];
if (!IsValidPluginId(pluginId)) return [];
return _windows.Values.Where(w => string.Equals(w.PluginId, pluginId, StringComparison.Ordinal)).ToArray();
}

Expand Down
130 changes: 130 additions & 0 deletions app/desktop/Nori.Plugin.Runtime.Tests/PluginRuntimeReviewTests.cs
Original file line number Diff line number Diff line change
@@ -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<PluginException>(() => 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<bool>());

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<PluginException>(() => 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 { }
}
}
16 changes: 3 additions & 13 deletions app/desktop/Nori.Plugin.Runtime/PluginContextServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "插件存储目录包含符号链接");
}
}
/// <summary>拒绝插件自己的存储目录是链接,但允许宿主运行于系统级 symlink 祖先之下。</summary>
private static void EnsureNoReparsePoints(string path) =>
PluginPathSafety.EnsureNoReparsePoint(path, PluginErrorCodes.StorageFailed, "插件存储目录包含符号链接");
}

/// <summary>插件包公开资源读取器。</summary>
Expand Down
29 changes: 10 additions & 19 deletions app/desktop/Nori.Plugin.Runtime/PluginLoadContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,17 @@ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
: IntPtr.Zero;
}

/// <summary>预扫描插件目录,拒绝宿主内部引用与重复 contract DLL。</summary>
/// <summary>预扫描插件目录,拒绝宿主内部引用、重复 contract DLL 与插件树内链接。</summary>
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<string> 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);
}
Expand All @@ -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)
{
Expand All @@ -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) ||
Expand Down
10 changes: 1 addition & 9 deletions app/desktop/Nori.Plugin.Runtime/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)]
Expand Down
26 changes: 9 additions & 17 deletions app/desktop/Nori.Plugin.Runtime/PluginPackageInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public PluginPackageInstaller(string root)
Directory.CreateDirectory(_stagingRoot);
Directory.CreateDirectory(_inboxRoot);
EnsureNoReparsePoints(_root);
EnsureNoReparsePoints(_stagingRoot);
EnsureNoReparsePoints(_inboxRoot);
}

public string RootDirectory => _root;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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, "插件包路径包含符号链接");
}
}
}
/// <summary>
/// 只检查宿主管理的目标节点本身,不检查其系统级祖先目录。
/// 插件目录树内部的链接由 PluginLoadContext 的锚定扫描负责拒绝。
/// </summary>
private static void EnsureNoReparsePoints(string path) =>
PluginPathSafety.EnsureNoReparsePoint(path, PluginErrorCodes.PackagePathDenied, "插件包路径包含符号链接");

private static void TryDelete(string path)
{
Expand Down
Loading
Loading