aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs5
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs20
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs14
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json2
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs97
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs16
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs28
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs31
-rw-r--r--Jellyfin.sln7
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs31
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs4
-rw-r--r--MediaBrowser.Controller/Persistence/IItemCountService.cs4
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs12
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs7
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs20
-rw-r--r--src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs339
-rw-r--r--tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs60
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj26
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs99
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs72
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs137
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs52
24 files changed, 1019 insertions, 68 deletions
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 2462a754ae..a2d3e14439 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -192,7 +192,7 @@ namespace Emby.Server.Implementations.Dto
var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
if (folderIds.Count > 0)
{
- childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user?.Id);
+ childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
}
}
@@ -700,7 +700,8 @@ namespace Emby.Server.Implementations.Dto
return count;
}
- // Fall back to individual query for special cases (Series, Season, etc.)
+ // Only reached when no batch was computed: the batch holds an entry for every folder it
+ // was asked about, zero included.
return folder.GetChildCount(user);
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index dd8c883684..c045f8558c 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1745,9 +1745,9 @@ namespace Emby.Server.Implementations.Library
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
}
- public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
+ public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
- return _countService.GetChildCountBatch(parentIds, userId);
+ return _countService.GetChildCountBatch(parentIds, user);
}
/// <inheritdoc/>
@@ -3852,7 +3852,9 @@ namespace Emby.Server.Implementations.Library
}
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
+ ?? throw new FileNotFoundException(
+ string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
CreateShortcut(virtualFolderPath, pathInfo);
@@ -3873,7 +3875,9 @@ namespace Emby.Server.Implementations.Library
ArgumentNullException.ThrowIfNull(mediaPath);
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName)
+ ?? throw new FileNotFoundException(
+ string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -3912,9 +3916,9 @@ namespace Emby.Server.Implementations.Library
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var path = Path.Combine(rootFolderPath, name);
+ var path = FileSystemHelper.GetChildPath(rootFolderPath, name);
- if (!Directory.Exists(path))
+ if (path is null || !Directory.Exists(path))
{
throw new FileNotFoundException("The media folder does not exist");
}
@@ -3978,9 +3982,9 @@ namespace Emby.Server.Implementations.Library
ArgumentException.ThrowIfNullOrEmpty(mediaPath);
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
- var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
+ var virtualFolderPath = FileSystemHelper.GetChildPath(rootFolderPath, virtualFolderName);
- if (!Directory.Exists(virtualFolderPath))
+ if (virtualFolderPath is null || !Directory.Exists(virtualFolderPath))
{
throw new FileNotFoundException(
string.Format(CultureInfo.InvariantCulture, "The media collection {0} does not exist", virtualFolderName));
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
index 0e180753a6..306a8673d5 100644
--- a/Emby.Server.Implementations/Library/Search/SearchManager.cs
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -112,13 +112,12 @@ public class SearchManager : ISearchManager
return externalResults;
}
- var internalResults = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
- return internalResults;
+ return await internalTask.ConfigureAwait(false);
}
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
@@ -144,17 +143,16 @@ public class SearchManager : ISearchManager
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
- var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
- if (allowedCount == candidates.Count)
- {
- return candidates;
- }
-
var allowedIds = await baseQuery
.Select(e => e.Id)
.ToHashSetAsync(cancellationToken)
.ConfigureAwait(false);
+ if (allowedIds.Count == candidates.Count)
+ {
+ return candidates;
+ }
+
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
if (filtered.Count < candidates.Count)
{
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 6aa72908cb..1a1c89da35 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -118,7 +118,7 @@
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
"NameExtraThemeVideo": "Eyðkenniskykmynd",
- "NameExtraDeletedScene": "Úrtikin mynd (scena)",
+ "NameExtraDeletedScene": "Úrtikin mynd",
"NameExtraScene": "Mynd (scena)",
"NameExtraUnknown": "Eykatilfar",
"Original": "Upprunalig(t/ur)"
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index f4aa0ad03a..94215bed79 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -309,7 +309,7 @@ namespace Emby.Server.Implementations.Session
{
if (!session.SessionControllers.Any(i => i.IsSessionActive))
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
if (!string.IsNullOrEmpty(session.PlayState?.LiveStreamId))
@@ -369,7 +369,7 @@ namespace Emby.Server.Implementations.Session
if (session is not null)
{
- var key = GetSessionKey(session.Client, session.DeviceId);
+ var key = GetSessionKey(session.Client, session.DeviceId, session.UserId);
_activeConnections.TryRemove(key, out _);
@@ -475,8 +475,11 @@ namespace Emby.Server.Implementations.Session
}
}
- private static string GetSessionKey(string appName, string deviceId)
- => appName + deviceId;
+ // The user is part of the key because the client name and the device id are taken from the
+ // request headers and are not bound to the access token. Without it, any authenticated user
+ // could claim another user's client/device pair and take over their session.
+ private static string GetSessionKey(string appName, string deviceId, Guid userId)
+ => appName + deviceId + userId.ToString("N", CultureInfo.InvariantCulture);
/// <summary>
/// Gets the connection.
@@ -500,7 +503,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
- var key = GetSessionKey(appName, deviceId);
+ var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
@@ -1537,11 +1540,52 @@ namespace Emby.Server.Implementations.Session
return SendMessageToSession(session, SessionMessageType.Playstate, command, cancellationToken);
}
- private static void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
+ private void AssertCanControl(SessionInfo session, SessionInfo controllingSession)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(controllingSession);
+
+ var controllingUserId = controllingSession.UserId;
+
+ // Controlling a session is always allowed when:
+ // - the caller has no associated user (an API key, which is a privileged context),
+ // - the target session is public (has no owning user), or
+ // - the caller's user is associated with the target session.
+ // Controlling a session owned by a different user requires the
+ // EnableRemoteControlOfOtherUsers permission.
+ if (controllingUserId.IsEmpty()
+ || session.UserId.IsEmpty()
+ || session.ContainsUser(controllingUserId))
+ {
+ return;
+ }
+
+ var controllingUser = _userManager.GetUserById(controllingUserId);
+ if (controllingUser is null
+ || !controllingUser.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
+ {
+ throw new SecurityException("The current user does not have permission to remote control other users.");
+ }
+ }
+
+ private void AssertCanAttachUser(SessionInfo controllingSession, Guid userId)
+ {
+ var controllingUserId = controllingSession.UserId;
+
+ // Playback reported by a session is also written to the user data of its additional users,
+ // so attaching anyone but the calling user requires administrative privileges.
+ if (controllingUserId.IsEmpty() || controllingUserId.Equals(userId))
+ {
+ return;
+ }
+
+ var controllingUser = _userManager.GetUserById(controllingUserId);
+ if (controllingUser is null
+ || !controllingUser.HasPermission(PermissionKind.IsAdministrator))
+ {
+ throw new SecurityException("The current user does not have permission to attach another user to a session.");
+ }
}
/// <summary>
@@ -1559,16 +1603,24 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Adds the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to attach the user to the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void AddAdditionalUser(string sessionId, Guid userId)
+ public void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ var controllingSession = GetSession(controllingSessionId);
+ AssertCanControl(session, controllingSession);
+ AssertCanAttachUser(controllingSession, userId);
+ }
+
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
@@ -1576,7 +1628,8 @@ namespace Emby.Server.Implementations.Session
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
{
- var user = _userManager.GetUserById(userId);
+ var user = _userManager.GetUserById(userId)
+ ?? throw new ArgumentException("The requested user does not exist.");
var newUser = new SessionUserInfo
{
UserId = userId,
@@ -1590,16 +1643,22 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Removes the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- /// <exception cref="UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception>
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
/// <exception cref="ArgumentException">The requested user is already the primary user of the session.</exception>
- public void RemoveAdditionalUser(string sessionId, Guid userId)
+ public void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
if (session.UserId.Equals(userId))
{
throw new ArgumentException("The requested user is already the primary user of the session.");
@@ -1803,14 +1862,21 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// Reports the capabilities.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
- public void ReportCapabilities(string sessionId, ClientCapabilities capabilities)
+ /// <exception cref="SecurityException">The controlling user is not allowed to control the session.</exception>
+ public void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities)
{
CheckDisposed();
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
ReportCapabilities(session, capabilities, true);
}
@@ -1905,13 +1971,18 @@ namespace Emby.Server.Implementations.Session
}
/// <inheritdoc />
- public void ReportNowViewingItem(string sessionId, string itemId)
+ public void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId)
{
ArgumentException.ThrowIfNullOrEmpty(itemId);
var item = _libraryManager.GetItemById(new Guid(itemId));
var session = GetSession(sessionId);
+ if (!string.IsNullOrEmpty(controllingSessionId))
+ {
+ AssertCanControl(session, GetSession(controllingSessionId));
+ }
+
session.NowViewingItem = GetItemInfo(item, null);
}
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index e46795554b..5c596c21b9 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Common.Api;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -122,12 +123,14 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="newName">The new name.</param>
/// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <response code="204">Folder renamed.</response>
+ /// <response code="400">The new name is not a valid library name.</response>
/// <response code="404">Library doesn't exist.</response>
/// <response code="409">Library already exists.</response>
- /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
+ /// <returns>A <see cref="NoContentResult"/> on success, a <see cref="BadRequestResult"/> if the new name is invalid, a <see cref="NotFoundResult"/> if the library doesn't exist, a <see cref="ConflictResult"/> if the new name is already taken.</returns>
/// <exception cref="ArgumentNullException">The new name may not be null.</exception>
[HttpPost("Name")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public ActionResult RenameVirtualFolder(
@@ -147,10 +150,15 @@ public class LibraryStructureController : BaseJellyfinApiController
var rootFolderPath = _appPaths.DefaultUserViewsPath;
- var currentPath = Path.Combine(rootFolderPath, name);
- var newPath = Path.Combine(rootFolderPath, newName);
+ // Both names are caller supplied, so they have to be confined to the libraries root.
+ var newPath = FileSystemHelper.GetChildPath(rootFolderPath, newName);
+ if (newPath is null)
+ {
+ return BadRequest("The new name is not a valid library name.");
+ }
- if (!Directory.Exists(currentPath))
+ var currentPath = FileSystemHelper.GetChildPath(rootFolderPath, name);
+ if (currentPath is null || !Directory.Exists(currentPath))
{
return NotFound("The media collection does not exist.");
}
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 048a49ffd4..9bfe0f2570 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -521,7 +521,7 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] ImageType[] enableImageTypes)
{
- var callingUserId = userId ?? User.GetUserId();
+ var callingUserId = RequestHelpers.GetUserId(User, userId);
var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
if (playlist is null)
{
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index a144961d74..84c2d90fb1 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -306,11 +306,14 @@ public class SessionController : BaseJellyfinApiController
[HttpPost("Sessions/{sessionId}/User/{userId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult AddUserToSession(
+ public async Task<ActionResult> AddUserToSession(
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
- _sessionManager.AddAdditionalUser(sessionId, userId);
+ _sessionManager.AddAdditionalUser(
+ await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+ sessionId,
+ userId);
return NoContent();
}
@@ -324,11 +327,14 @@ public class SessionController : BaseJellyfinApiController
[HttpDelete("Sessions/{sessionId}/User/{userId}")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult RemoveUserFromSession(
+ public async Task<ActionResult> RemoveUserFromSession(
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
- _sessionManager.RemoveAdditionalUser(sessionId, userId);
+ _sessionManager.RemoveAdditionalUser(
+ await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
+ sessionId,
+ userId);
return NoContent();
}
@@ -352,12 +358,13 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsPersistentIdentifier = true)
{
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(id))
{
- id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ id = currentSessionId;
}
- _sessionManager.ReportCapabilities(id, new ClientCapabilities
+ _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
{
PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands,
@@ -381,12 +388,13 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] string? id,
[FromBody, Required] ClientCapabilitiesDto capabilities)
{
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(id))
{
- id = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ id = currentSessionId;
}
- _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
+ _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
return NoContent();
}
@@ -405,9 +413,9 @@ public class SessionController : BaseJellyfinApiController
[FromQuery] string? sessionId,
[FromQuery, Required] string? itemId)
{
- string session = sessionId ?? await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
+ var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
- _sessionManager.ReportNowViewingItem(session, itemId);
+ _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
return NoContent();
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index c42b5f9581..14b120363f 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -319,7 +319,7 @@ public class ItemCountService : IItemCountService
}
/// <inheritdoc/>
- public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
+ public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
ArgumentNullException.ThrowIfNull(parentIds);
@@ -332,20 +332,32 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
+ var includeVirtual = user is null || user.DisplayMissingEpisodes;
+
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue)
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ // An episode is a child of its season even when it is not stored under one: with a flat
+ // structure ParentId points at the series, so counting by ParentId alone leaves the season
+ // empty and counts its episodes towards the series instead.
+ var seasonCounts = dbContext.BaseItems
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
+ .GroupBy(b => b.SeasonId!.Value)
+ .Select(g => new { SeasonId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeasonId, x => x.Count);
+
var linkedCounts = dbContext.LinkedChildren
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
- var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
@@ -356,7 +368,8 @@ public class ItemCountService : IItemCountService
continue;
}
- var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
+ var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
+ + seasonCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
@@ -365,7 +378,7 @@ public class ItemCountService : IItemCountService
return result;
}
- private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
@@ -380,10 +393,16 @@ public class ItemCountService : IItemCountService
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
var children = dbContext.BaseItems
.AsNoTracking()
- .Where(b => b.ParentId.HasValue)
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
+ .Concat(dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
+ .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray())
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
diff --git a/Jellyfin.sln b/Jellyfin.sln
index b0d5a5eb47..b666e4ae16 100644
--- a/Jellyfin.sln
+++ b/Jellyfin.sln
@@ -99,6 +99,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implement
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -265,6 +267,10 @@ Global
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -297,6 +303,7 @@ Global
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
+ {E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
index 44b7fadf5e..f636258191 100644
--- a/MediaBrowser.Controller/IO/FileSystemHelper.cs
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -166,4 +166,35 @@ public static class FileSystemHelper
return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
}
+
+ /// <summary>
+ /// Combines a caller supplied name with a parent directory, making sure the name cannot escape that directory.
+ /// </summary>
+ /// <param name="parentPath">The directory the name has to resolve inside of.</param>
+ /// <param name="name">The name of the child.</param>
+ /// <returns>
+ /// The full path of the child, or <c>null</c> if <paramref name="name"/> is not the name of a direct child
+ /// of <paramref name="parentPath"/>.
+ /// </returns>
+ public static string? GetChildPath(string parentPath, string name)
+ {
+ if (string.IsNullOrWhiteSpace(name) || name.Contains('\0', StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ // Rejects directory separators, and on Windows also volume separators, as those make the name more than a single segment.
+ if (!string.Equals(Path.GetFileName(name), name, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ var fullPath = Path.GetFullPath(Path.Combine(parentPath, name));
+ var fullParentPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parentPath));
+
+ // Catches the remaining relative names, "." and "..", which are valid single segments.
+ return string.Equals(Path.GetDirectoryName(fullPath), fullParentPath, StringComparison.Ordinal)
+ ? fullPath
+ : null;
+ }
}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 2a6ea214b8..c8cca1fa93 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -758,9 +758,9 @@ namespace MediaBrowser.Controller.Library
/// Returns the count of immediate children (non-recursive) for each parent.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
- /// <param name="userId">The user ID for access filtering.</param>
+ /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
- Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
+ Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
/// <summary>
/// Batch-fetches played and total counts for multiple folder items.
diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs
index d57f1fc893..8ddf93e3e0 100644
--- a/MediaBrowser.Controller/Persistence/IItemCountService.cs
+++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs
@@ -80,7 +80,7 @@ public interface IItemCountService
/// Batch-fetches child counts for multiple parent folders.
/// </summary>
/// <param name="parentIds">The list of parent folder IDs.</param>
- /// <param name="userId">The user ID for access filtering.</param>
+ /// <param name="user">The user the counts are for, or null to count without a user's preferences.</param>
/// <returns>Dictionary mapping parent ID to child count.</returns>
- Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId);
+ Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user);
}
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index c11c65c334..9acff745b9 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -238,23 +238,26 @@ namespace MediaBrowser.Controller.Session
/// <summary>
/// Adds the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- void AddAdditionalUser(string sessionId, Guid userId);
+ void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Removes the additional user.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
- void RemoveAdditionalUser(string sessionId, Guid userId);
+ void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Reports the now viewing item.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="itemId">The item identifier.</param>
- void ReportNowViewingItem(string sessionId, string itemId);
+ void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <summary>
/// Authenticates the new session.
@@ -268,9 +271,10 @@ namespace MediaBrowser.Controller.Session
/// <summary>
/// Reports the capabilities.
/// </summary>
+ /// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
- void ReportCapabilities(string sessionId, ClientCapabilities capabilities);
+ void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
/// <summary>
/// Reports the transcoding information.
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 803fab538f..b350f482c3 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
foreach (var episode in episodes)
{
var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
- if (season is null || (episode.SeasonId.Equals(season.Id) && episode.ParentId.Equals(season.Id)))
+ if (season is null || episode.SeasonId.Equals(season.Id))
{
continue;
}
@@ -372,11 +372,6 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
// Assign the correct season id and name to episode.
episode.SeasonId = season.Id;
episode.SeasonName = season.Name;
-
- // We need to set ParentId here for episodes in virtual seasons (e.g., flat structures), otherwise it retains the
- // ParentId from the series.
- episode.SetParent(season);
-
await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index b6d2914efa..3e353db8de 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Model.Drawing;
using Microsoft.Extensions.Logging;
using SkiaSharp;
+using Svg;
using Svg.Skia;
namespace Jellyfin.Drawing.Skia;
@@ -48,6 +49,13 @@ public class SkiaEncoder : IImageEncoder
/// </summary>
public static readonly SKSamplingOptions DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
+ static SkiaEncoder()
+ {
+ SvgDocument.ResolveExternalElements = ExternalType.None;
+ SvgDocument.ResolveExternalImages = ExternalType.None;
+ SvgDocument.ResolveExternalXmlEntites = ExternalType.None;
+ }
+
/// <summary>
/// Initializes a new instance of the <see cref="SkiaEncoder"/> class.
/// </summary>
@@ -183,6 +191,12 @@ public class SkiaEncoder : IImageEncoder
var extension = Path.GetExtension(path.AsSpan());
if (extension.Equals(".svg", StringComparison.OrdinalIgnoreCase))
{
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to determine dimensions for SVG {FilePath}: {Reason}", path, reason);
+ return default;
+ }
+
using var svg = new SKSvg();
try
{
@@ -445,6 +459,12 @@ public class SkiaEncoder : IImageEncoder
throw new FileNotFoundException("File not found", path);
}
+ if (!SvgSecurityValidator.IsSafe(path, out var reason))
+ {
+ _logger.LogError("Refusing to render SVG {FilePath}: {Reason}", path, reason);
+ return null;
+ }
+
using var svg = SKSvg.CreateFromFile(path);
if (svg.Drawable is null)
{
diff --git a/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
new file mode 100644
index 0000000000..f8a1d7d443
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SvgSecurityValidator.cs
@@ -0,0 +1,339 @@
+using System;
+using System.Buffers;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.IO.Compression;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Xml;
+
+[assembly: InternalsVisibleTo("Jellyfin.Drawing.Skia.Tests")]
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// Validates that an SVG document does not reference external resources before it is rasterized.
+/// </summary>
+internal static class SvgSecurityValidator
+{
+ // Guards against a chain of nested data:image/svg+xml payloads.
+ private const int MaxDataUriDepth = 4;
+
+ // Upper bound for a decompressed svgz payload carried inside a data URI, to guard against decompression bombs.
+ private const int MaxDecompressedBytes = 16 * 1024 * 1024;
+
+ private const int DecompressBufferSize = 81920;
+
+ private static readonly XmlReaderSettings _scanSettings = new()
+ {
+ DtdProcessing = DtdProcessing.Parse,
+ XmlResolver = null,
+ MaxCharactersFromEntities = 1024 * 1024,
+ IgnoreComments = true,
+ IgnoreProcessingInstructions = true,
+ IgnoreWhitespace = true,
+ CloseInput = false
+ };
+
+ /// <summary>
+ /// Determines whether the SVG at the given path is safe to rasterize, i.e. contains no references
+ /// to external resources.
+ /// </summary>
+ /// <param name="path">The path to the SVG file.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(string path, [NotNullWhen(false)] out string? reason)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+ reason = Validate(stream, 0);
+ }
+ catch (IOException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ reason = "Unable to read the file for validation: " + ex.Message;
+ }
+
+ return reason is null;
+ }
+
+ /// <summary>
+ /// Determines whether the SVG in the given stream is safe to rasterize.
+ /// </summary>
+ /// <param name="stream">The stream containing the SVG document.</param>
+ /// <param name="reason">When this method returns <c>false</c>, the reason the document was rejected.</param>
+ /// <returns><c>true</c> if the document is free of external references; otherwise <c>false</c>.</returns>
+ public static bool IsSafe(Stream stream, [NotNullWhen(false)] out string? reason)
+ {
+ reason = Validate(stream, 0);
+ return reason is null;
+ }
+
+ private static string? Validate(Stream stream, int depth)
+ {
+ try
+ {
+ using var reader = XmlReader.Create(stream, _scanSettings);
+ while (reader.Read())
+ {
+ switch (reader.NodeType)
+ {
+ case XmlNodeType.DocumentType:
+ {
+ var subset = reader.Value;
+ if (!string.IsNullOrEmpty(subset)
+ && (subset.Contains("SYSTEM", StringComparison.OrdinalIgnoreCase)
+ || subset.Contains("PUBLIC", StringComparison.OrdinalIgnoreCase)))
+ {
+ return "The document declares an external DTD entity";
+ }
+
+ break;
+ }
+
+ case XmlNodeType.Element when reader.HasAttributes:
+ {
+ for (var i = 0; i < reader.AttributeCount; i++)
+ {
+ reader.MoveToAttribute(i);
+ var isHref = reader.LocalName.Equals("href", StringComparison.OrdinalIgnoreCase);
+ var reason = isHref
+ ? ValidateReference(reader.Value, depth, "href")
+ : ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+
+ reader.MoveToElement();
+ break;
+ }
+
+ case XmlNodeType.Text:
+ case XmlNodeType.CDATA:
+ {
+ var reason = ValidateCss(reader.Value, depth);
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ break;
+ }
+ }
+ }
+
+ return null;
+ }
+ catch (XmlException ex)
+ {
+ // Malformed markup, a forbidden DTD construct or an unresolved external entity: refuse to render.
+ return "The document could not be safely parsed: " + ex.Message;
+ }
+ }
+
+ private static string? ValidateReference(ReadOnlySpan<char> value, int depth, string context)
+ {
+ var trimmed = value.Trim();
+ if (trimmed.IsEmpty || trimmed[0] == '#')
+ {
+ return null;
+ }
+
+ if (trimmed.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
+ {
+ return ValidateDataUri(trimmed, depth, context);
+ }
+
+ return "An external resource is referenced via " + context;
+ }
+
+ private static string? ValidateDataUri(ReadOnlySpan<char> dataUri, int depth, string context)
+ {
+ // "data:[<mediatype>][;base64],<payload>" (mirrors Svg.Model's data URI parsing).
+ var comma = dataUri.IndexOf(',');
+ if (comma < 0)
+ {
+ return "A malformed data URI is referenced via " + context;
+ }
+
+ var header = dataUri[5..comma];
+ var firstSeparator = header.IndexOf(';');
+ var mediaType = (firstSeparator < 0 ? header : header[..firstSeparator]).Trim();
+
+ // Only "image/svg+xml" is re-parsed as SVG by the renderer; any other type is treated as raster data.
+ if (!mediaType.Contains('/') || !mediaType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ if (depth >= MaxDataUriDepth)
+ {
+ return "Nested data URIs exceed the allowed depth";
+ }
+
+ var lastSeparator = header.LastIndexOf(';');
+ var isBase64 = lastSeparator >= 0
+ && header[(lastSeparator + 1)..].Trim().Equals("base64", StringComparison.OrdinalIgnoreCase);
+
+ var payload = dataUri[(comma + 1)..].Trim();
+ byte[]? buffer = null;
+ try
+ {
+ int length;
+ if (isBase64)
+ {
+ buffer = ArrayPool<byte>.Shared.Rent((payload.Length / 4 * 3) + 3);
+ if (!Convert.TryFromBase64Chars(payload, buffer, out length))
+ {
+ return "An undecodable data URI is referenced via " + context;
+ }
+ }
+ else
+ {
+ var unescaped = Uri.UnescapeDataString(payload.ToString());
+ buffer = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(unescaped.Length));
+ length = Encoding.UTF8.GetBytes(unescaped, buffer);
+ }
+
+ if (length > 2 && buffer[0] == 0x1F && buffer[1] == 0x8B)
+ {
+ using var decompressed = Decompress(buffer, length);
+ return Validate(decompressed, depth + 1);
+ }
+
+ using var stream = new MemoryStream(buffer, 0, length, false);
+ return Validate(stream, depth + 1);
+ }
+ catch (FormatException ex)
+ {
+ return "An undecodable data URI is referenced via " + context + ": " + ex.Message;
+ }
+ catch (InvalidDataException ex)
+ {
+ return "An invalid compressed data URI is referenced via " + context + ": " + ex.Message;
+ }
+ finally
+ {
+ if (buffer is not null)
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+ }
+
+ private static MemoryStream Decompress(byte[] compressed, int length)
+ {
+ using var input = new MemoryStream(compressed, 0, length, false);
+ using var gzip = new GZipStream(input, CompressionMode.Decompress);
+ var output = new MemoryStream();
+ var buffer = ArrayPool<byte>.Shared.Rent(DecompressBufferSize);
+ try
+ {
+ var total = 0;
+ int read;
+ while ((read = gzip.Read(buffer, 0, buffer.Length)) > 0)
+ {
+ total += read;
+ if (total > MaxDecompressedBytes)
+ {
+ throw new InvalidDataException("Compressed data URI exceeds the allowed size");
+ }
+
+ output.Write(buffer, 0, read);
+ }
+ }
+ catch
+ {
+ output.Dispose();
+ throw;
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+
+ output.Position = 0;
+ return output;
+ }
+
+ private static string? ValidateCss(ReadOnlySpan<char> value, int depth)
+ {
+ if (value.IsEmpty)
+ {
+ return null;
+ }
+
+ var index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("url(", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var start = index + found + 4;
+ var close = value[start..].IndexOf(')');
+ if (close < 0)
+ {
+ break;
+ }
+
+ var target = value.Slice(start, close).Trim();
+ target = target.Trim('\'');
+ target = target.Trim('"').Trim();
+ var reason = ValidateReference(target, depth, "url()");
+ if (reason is not null)
+ {
+ return reason;
+ }
+
+ index = start + close + 1;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ // Handle the bare "@import '...';" form (the "@import url(...)" form is covered above).
+ index = 0;
+ while (true)
+ {
+ var found = value[index..].IndexOf("@import", StringComparison.OrdinalIgnoreCase);
+ if (found < 0)
+ {
+ break;
+ }
+
+ var rest = value[(index + found + 7)..];
+ var quote = rest.IndexOfAny('\'', '"');
+ if (quote >= 0)
+ {
+ var afterQuote = rest[(quote + 1)..];
+ var end = afterQuote.IndexOfAny('\'', '"');
+ if (end >= 0)
+ {
+ var reason = ValidateReference(afterQuote[..end], depth, "@import");
+ if (reason is not null)
+ {
+ return reason;
+ }
+ }
+ }
+
+ index = index + found + 7;
+ if (index >= value.Length)
+ {
+ break;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
new file mode 100644
index 0000000000..4c7addd164
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller.IO;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.IO;
+
+public class FileSystemHelperTests
+{
+ private static readonly string _parentPath = Path.Combine(Path.GetTempPath(), "jellyfin-test", "root", "default");
+
+ [Theory]
+ [InlineData("Movies")]
+ [InlineData("My Movies")]
+ [InlineData("..2")]
+ [InlineData("...")]
+ [InlineData("a.b")]
+ public void GetChildPath_ValidName_ReturnsPathInsideParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ Assert.Equal(Path.Combine(_parentPath, name), path);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(".")]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("../../etc")]
+ [InlineData("Movies/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ [InlineData("sub/folder")]
+ [InlineData("with\0null")]
+ public void GetChildPath_EscapingName_ReturnsNull(string name)
+ {
+ Assert.Null(FileSystemHelper.GetChildPath(_parentPath, name));
+ }
+
+ [Theory]
+ [InlineData("..\\..")]
+ [InlineData("sub\\folder")]
+ [InlineData("C:\\Windows")]
+ public void GetChildPath_WindowsSeparator_DoesNotEscapeParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ // On Windows these are rejected outright, on other platforms a backslash is a legal file name character.
+ Assert.True(path is null || string.Equals(Path.GetDirectoryName(path), _parentPath, StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void GetChildPath_ParentWithTrailingSeparator_ReturnsPathInsideParent()
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath + Path.DirectorySeparatorChar, "Movies");
+
+ Assert.Equal(Path.Combine(_parentPath, "Movies"), path);
+ }
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
new file mode 100644
index 0000000000..b6dc5dfb92
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
+ <PropertyGroup>
+ <ProjectGuid>{E24A279C-9A37-419A-8F9C-853C11FBE753}</ProjectGuid>
+ <OutputType>Exe</OutputType>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ <PackageReference Include="xunit.runner.visualstudio">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="coverlet.collector">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="../../src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
new file mode 100644
index 0000000000..30b7983ece
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+public static class SvgSecurityValidatorTests
+{
+ public static TheoryData<string> ExternalReferenceSvgs => new()
+ {
+ // SSRF via <image> (xlink:href and plain href)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='http://169.254.169.254/latest/meta-data/' width='16' height='16'/></svg>",
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><image href='https://example.invalid/a.png' width='16' height='16'/></svg>",
+ // Local file disclosure
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///etc/passwd' width='16' height='16'/></svg>",
+ // Memory exhaustion DoS
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///dev/urandom' width='16' height='16'/></svg>",
+ // <use> external reference
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><use xlink:href='http://example.invalid/c.svg#a'/></svg>",
+ // CSS url() external reference in an attribute
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' style=\"fill:url(http://example.invalid/d.svg#g)\"/></svg>",
+ // @import in a style block
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><style>@import 'http://example.invalid/e.css';</style><rect width='16' height='16'/></svg>",
+ // Relative path traversal (resolves against the document location -> local file read)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='../../../../etc/hosts' width='16' height='16'/></svg>",
+ // XXE via external entity
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&xxe;</text></svg>",
+ // Entity-expansion (billion laughs) denial of service
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY a 'aaaaaaaaaa'><!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'><!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'><!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'><!ENTITY e '&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;'><!ENTITY f '&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&f;</text></svg>",
+ // Nested SVG in a base64 data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jz48aW1hZ2UgeGxpbms6aHJlZj0naHR0cDovL2V4YW1wbGUuaW52YWxpZC9uZXN0ZWQucG5nJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jy8+PC9zdmc+' width='16' height='16'/></svg>",
+ // Nested SVG in a URL-encoded (non-base64) data: URI referencing an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20xmlns%3Axlink%3D%27http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%27%3E%3Cimage%20xlink%3Ahref%3D%27file%3A%2F%2F%2Fetc%2Fpasswd%27%2F%3E%3C%2Fsvg%3E' width='16' height='16'/></svg>",
+ // Nested gzip-compressed (svgz) data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/23OwQrDIBAE0F/x5s217aWK8V+E2N2laiWRKP36Nin0lNvAPIZx64Zi5FTWSVJr1QL03lW/qdeCcNVaw1fIH7EjcXmewYsxBo5Wis5zo0nepaDISG2P3nEOGMVBLC3x8V+JI+SaouKyhcQz4FvVgucz4N1+x38AdK4P3LYAAAA=' width='16' height='16'/></svg>",
+ };
+
+ public static TheoryData<string> SafeSvgs => new()
+ {
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='red'/></svg>",
+ // Same-document fragment references are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><defs><linearGradient id='g'/></defs><rect width='16' height='16' fill='url(#g)'/><use xlink:href='#g'/></svg>",
+ // Inline data URIs are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' width='16' height='16'/></svg>",
+ // A DOCTYPE without external entities is allowed
+ "<?xml version='1.0'?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>",
+ // An internal general entity with no external reference is allowed (and is expanded by the renderer)
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY col 'red'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='&col;'/></svg>",
+ // A nested data:image/svg+xml payload that is itself self-contained is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc4JyBoZWlnaHQ9JzgnPjxyZWN0IHdpZHRoPSc4JyBoZWlnaHQ9JzgnIGZpbGw9J2JsdWUnLz48L3N2Zz4=' width='16' height='16'/></svg>",
+ // A self-contained gzip-compressed (svgz) data: URI is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/22Muw6AIAwAf6VbN0p0MQb4GBWBBB+Bav18ZXe75C5n6h3g2fJeLUbmcyQSESW9OkqgTmtNX4EgaeFocUCIPoXIDZ0pfuZfBWvK2eKUL4/kTHu4F2NB6oFrAAAA' width='16' height='16'/></svg>",
+ };
+
+ [Theory]
+ [MemberData(nameof(ExternalReferenceSvgs))]
+ public static void IsSafe_ExternalReference_ReturnsFalse(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.NotNull(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeSvgs))]
+ public static void IsSafe_NoExternalReference_ReturnsTrue(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.True(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.Null(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public static void IsSafe_MissingFile_ReturnsFalse()
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName() + ".svg"), out var reason));
+ Assert.NotNull(reason);
+ }
+
+ private static string WriteTemp(string svg)
+ {
+ var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".svg");
+ File.WriteAllText(path, svg);
+ return path;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index bdac59c013..679e6d17e3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -154,7 +154,7 @@ public class DtoServiceTests
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
_libraryManagerMock
- .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
return (season, user);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index 947cf54d85..fea743f08e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
Assert.Equal(2, result[seriesB]);
}
+ [Fact]
+ public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+
+ // The series holds the season, not the episodes: counting those here would double them up.
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+ var user = new User("count-test", "provider", "reset");
+
+ user.DisplayMissingEpisodes = true;
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+
+ // Nothing this user can open, so nothing to report.
+ user.DisplayMissingEpisodes = false;
+ Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
+ }
+
+ private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
+ {
+ var seriesId = Guid.NewGuid();
+ var seasonId = Guid.NewGuid();
+
+ using var context = CreateDbContext();
+ context.BaseItems.Add(CreateItem(seriesId));
+ context.BaseItems.Add(CreateItem(seasonId, seriesId));
+
+ // Flat: the episodes sit in the series folder, so ParentId points at the series and only
+ // SeasonId ties them to the season they belong to.
+ for (var i = 0; i < 2; i++)
+ {
+ var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
+ episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
+ episode.IsFolder = false;
+ episode.IsVirtualItem = virtualEpisodes;
+ episode.SeasonId = seasonId;
+ context.BaseItems.Add(episode);
+ }
+
+ context.SaveChanges();
+
+ return (seriesId, seasonId);
+ }
+
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
{
var user = new User("count-test", "provider", "reset");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
index a5a67046d1..f803c69af2 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
@@ -1,6 +1,9 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
@@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -108,4 +113,136 @@ public class SessionManagerTests
return data;
}
+
+ [Fact]
+ public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
+ attackerSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "test exploit!" },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("controller", "default", "default");
+ attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var controllingSession = await LogSessionActivity(sessionManager, attacker);
+
+ await sessionManager.SendMessageCommand(
+ controllingSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "hello" },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ // Client name and device id are attacker controlled, so they must not identify a session on their own.
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.NotEqual(victimSession.Id, attackerSession.Id);
+ Assert.Equal(victim.Id, victimSession.UserId);
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser()
+ {
+ var attacker = new User("attacker", "default", "default");
+ var victim = new User("victim", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator()
+ {
+ var admin = new User("admin", "default", "default");
+ admin.SetPermission(PermissionKind.IsAdministrator, true);
+ var guest = new User("guest", "default", "default");
+ await using var sessionManager = CreateSessionManager(admin, guest);
+
+ var adminSession = await LogSessionActivity(sessionManager, admin);
+
+ sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
+
+ Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
+ }
+
+ [Fact]
+ public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
+ }
+
+ [Fact]
+ public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
+ }
+
+ private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
+ {
+ var userManager = new Mock<IUserManager>();
+ foreach (var user in users)
+ {
+ userManager.Setup(i => i.GetUserById(user.Id)).Returns(user);
+ }
+
+ return new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ userManager.Object,
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+ }
+
+ // All sessions are logged with the same client and device id on purpose, those values are taken
+ // from the request headers and are not bound to the access token of the calling user.
+ private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
+ => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
index 2de6408cc6..0a5838c545 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -114,6 +114,58 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task DeleteLibrary_PathTraversal_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync($"Library/VirtualFolders?name={Uri.EscapeDataString(name)}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalNewName_BadRequest(string newName)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name=test&newName={Uri.EscapeDataString(newName)}",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalName_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name={Uri.EscapeDataString(name)}&newName=renamed",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
[Fact]
[Priority(1)]
public async Task DeleteLibrary_Valid_Success()