Fix path transversal exposure in Plugins

The request path is not validated to a valid path and could allow escaping the transcode path and downloading of any arbitrary file in GetHlsPlaylistLegacy .

GetHlsAudioSegmentLegacy and GetHlsVideoSegmentLegacy have the same issue, and are NOT behind an Authorize so they are publicly exploitable.

Added a ValidateTranscodePath that verifies that requested file paths start with the transcode path setting. Also ensure that all filename comparisons are OrdinalIgnoreCase because we might be running on a filesystem where filename-casing doesn't have to match. Switched from InvariantCulture because the underlying OS filename comparisons are always byte-wise (with case insensitivity here).

Fixed a similar issue in GetPluginImage
This commit is contained in:
Marc Brooks
2026-04-01 16:55:47 -05:00
parent b9db4566a7
commit 617ebf367f
2 changed files with 26 additions and 23 deletions
@@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
{ {
// TODO: Deprecate with new iOS app // TODO: Deprecate with new iOS app
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
var transcodePath = _serverConfigurationManager.GetTranscodePath(); if (file is null)
file = Path.GetFullPath(Path.Combine(transcodePath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
@@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
{ {
var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())));
var transcodePath = _serverConfigurationManager.GetTranscodePath(); if (file is null
file = Path.GetFullPath(Path.Combine(transcodePath, file)); || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
|| Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
@@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController
[FromRoute, Required] string segmentId, [FromRoute, Required] string segmentId,
[FromRoute, Required] string segmentContainer) [FromRoute, Required] string segmentContainer)
{ {
var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); if (file is null)
file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
{ {
return BadRequest("Invalid segment."); return BadRequest("Invalid segment.");
} }
var normalizedPlaylistId = playlistId; var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath); var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
// Add . to start of segment container for future use. // Add . to start of segment container for future use.
segmentContainer = segmentContainer.Insert(0, "."); segmentContainer = segmentContainer.Insert(0, ".");
@@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController
var pathExtension = Path.GetExtension(path); var pathExtension = Path.GetExtension(path);
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase) if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase)) || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
&& path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase)) && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase))
{ {
playlistPath = path; playlistPath = path;
break; break;
@@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController
: GetFileResult(file, playlistPath); : GetFileResult(file, playlistPath);
} }
private string? ValidateTranscodePath(string filename)
{
var transcodePath = _serverConfigurationManager.GetTranscodePath();
var file = Path.GetFullPath(filename, transcodePath);
var fileDir = Path.GetDirectoryName(file);
if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return file;
}
private ActionResult GetFileResult(string path, string playlistPath) private ActionResult GetFileResult(string path, string playlistPath)
{ {
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls); var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
@@ -226,10 +226,11 @@ public class PluginsController : BaseJellyfinApiController
return NotFound(); return NotFound();
} }
if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath)) string? imagePath = plugin.Manifest.ImagePath;
if (!string.IsNullOrWhiteSpace(imagePath))
{ {
var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath); imagePath = Path.GetFullPath(imagePath, plugin.Path);
if (!System.IO.File.Exists(imagePath)) if (imagePath.StartsWith(plugin.Path, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false)
{ {
return NotFound(); return NotFound();
} }