Reject unsafe plugin package names in installer

This commit is contained in:
David Federman
2026-06-02 23:12:50 -07:00
parent 7185257da5
commit 5104497331
2 changed files with 67 additions and 0 deletions

View File

@@ -521,9 +521,27 @@ namespace Emby.Server.Implementations.Updates
return;
}
if (!IsValidPackageDirectoryName(package.Name))
{
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
}
// Always override the passed-in target (which is a file) and figure it out again
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
var resolvedTarget = Path.GetFullPath(targetDir);
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
package.Name,
resolvedTarget,
pluginsRoot);
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
@@ -572,6 +590,31 @@ namespace Emby.Server.Implementations.Updates
_pluginManager.ImportPluginFrom(targetDir);
}
private static bool IsValidPackageDirectoryName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
{
return false;
}
if (name.Contains('/', StringComparison.Ordinal) || name.Contains('\\', StringComparison.Ordinal))
{
return false;
}
if (name.AsSpan().IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return false;
}
return true;
}
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))

View File

@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
Assert.Null(ex);
}
[Theory]
[InlineData("../evil")]
[InlineData("..\\evil")]
[InlineData("../../escape_attempt")]
[InlineData("..")]
[InlineData(".")]
[InlineData("")]
[InlineData(" ")]
[InlineData("foo/bar")]
[InlineData("foo\\bar")]
[InlineData("/absolute")]
[InlineData("foo\0bar")]
public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
{
var packageInfo = new InstallationInfo()
{
Name = name,
SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
};
await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
}
}
}