Sanitize ClientLog upload filename to prevent path traversal

This commit is contained in:
Shadowghost
2026-07-17 16:32:40 +02:00
parent a96dc8bd9b
commit 4fb779920a
3 changed files with 130 additions and 1 deletions
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
{
throw new ArgumentException("Path resolved to filename not in log directory");
}
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.IO;
namespace Jellyfin.Extensions;
/// <summary>
/// Helpers for safely composing filesystem paths from untrusted input.
/// </summary>
/// <remarks>
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
/// any code that joins a trusted directory with an externally-supplied name:
/// it neither normalises <c>..</c> nor rejects a rooted second argument
/// (a rooted second arg silently discards the first). Use the helpers below
/// any time the name comes from media metadata, request input, archive
/// entries, or any other channel that can be influenced by a third party.
/// </remarks>
public static class PathHelper
{
/// <summary>
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
/// directory components.
/// </summary>
/// <param name="fileName">The candidate file name.</param>
/// <returns>
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
/// </returns>
public static string? GetSafeLeafFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var leaf = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
{
return null;
}
return leaf;
}
/// <summary>
/// Returns whether <paramref name="candidate"/> resolves to a path that
/// equals or is contained inside <paramref name="root"/>.
/// </summary>
/// <param name="root">The directory the candidate must remain inside.</param>
/// <param name="candidate">The candidate absolute or relative path.</param>
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
/// <remarks>
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
/// so <c>..</c> segments are collapsed before the comparison. The root is
/// compared with a trailing directory separator to prevent prefix
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
/// <c>/var/dataset</c>).
/// </remarks>
public static bool IsContainedIn(string root, string candidate)
{
ArgumentException.ThrowIfNullOrEmpty(root);
ArgumentException.ThrowIfNullOrEmpty(candidate);
var fullRoot = Path.GetFullPath(root);
var fullCandidate = Path.GetFullPath(candidate);
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
{
return true;
}
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
? fullRoot
: fullRoot + Path.DirectorySeparatorChar;
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
}
}
@@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.ClientEvent;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests
{
public class ClientEventLoggerTests
{
[Theory]
[InlineData("../../../../etc/passwd", "1.0")]
[InlineData("..\\..\\windows\\system32", "1.0")]
[InlineData("normal-client", "../../../etc/passwd")]
[InlineData("/absolute/path", "1.0")]
public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
{
var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
Directory.CreateDirectory(logDir);
try
{
var paths = new Mock<IServerApplicationPaths>();
paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
var logger = new ClientEventLogger(paths.Object);
using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
Assert.True(File.Exists(resolved));
}
finally
{
Directory.Delete(logDir, recursive: true);
}
}
}
}