diff options
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() |
