Sanitize media attachment and lyric paths against traversal

This commit is contained in:
Shadowghost
2026-07-17 16:46:13 +02:00
parent 4fb779920a
commit 1a45fc82b5
4 changed files with 81 additions and 5 deletions
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
var folder = GetAttachmentFolderPath(mediaSourceId);
return folder is null ? null : Path.Combine(folder, fileName);
if (folder is null)
{
return null;
}
var safeName = PathHelper.GetSafeLeafFileName(fileName);
if (safeName is null)
{
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
return null;
}
return Path.Combine(folder, safeName);
}
/// <inheritdoc />
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
&& PathHelper.GetSafeLeafFileName(a.FileName) != a.FileName);
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
await ExtractAllAttachmentsIndividuallyInternal(
@@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
+2 -2
View File
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
@@ -0,0 +1,60 @@
using System.IO;
using Jellyfin.Extensions;
using Xunit;
namespace Jellyfin.Extensions.Tests
{
public static class PathHelperTests
{
[Theory]
[InlineData("file.txt", "file.txt")]
[InlineData("sub/file.txt", "file.txt")]
[InlineData("../../etc/passwd", "passwd")]
public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected)
{
Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(".")]
[InlineData("..")]
public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input)
{
Assert.Null(PathHelper.GetSafeLeafFileName(input));
}
[Fact]
public static void IsContainedIn_ChildPath_ReturnsTrue()
{
var root = Path.Combine(Path.GetTempPath(), "root");
var child = Path.Combine(root, "sub", "file.txt");
Assert.True(PathHelper.IsContainedIn(root, child));
}
[Fact]
public static void IsContainedIn_RootItself_ReturnsTrue()
{
var root = Path.Combine(Path.GetTempPath(), "root");
Assert.True(PathHelper.IsContainedIn(root, root));
}
[Fact]
public static void IsContainedIn_TraversalEscape_ReturnsFalse()
{
var root = Path.Combine(Path.GetTempPath(), "root");
var escape = Path.Combine(root, "..", "..", "etc", "passwd");
Assert.False(PathHelper.IsContainedIn(root, escape));
}
[Fact]
public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse()
{
// "/var/data" must not be accepted as a parent of "/var/dataset".
var root = Path.Combine(Path.GetTempPath(), "data");
var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt");
Assert.False(PathHelper.IsContainedIn(root, sibling));
}
}
}