Compare commits

...

36 Commits

Author SHA1 Message Date
Jellyfin Release Bot
1fbd873929 Bump version to 10.11.11 2026-06-06 11:37:28 -04:00
Bond-009
39958ad9e5 Merge pull request #16944 from JPVenson/fix/UserManagerCallguard
Add lockhelper for UserManager
2026-06-03 19:49:57 +02:00
JPVenson
7bde1ac224 Add tests 2026-05-31 17:23:31 +00:00
JPVenson
143aee7e9e add hack for LDAP plugin 2026-05-31 15:12:49 +00:00
JPVenson
8c65dfefa1 Add more explicit copy operation in Update 2026-05-31 13:38:26 +00:00
JPVenson
869d8d3abc revert to block scope 2026-05-27 16:49:54 +00:00
JPVenson
8d0534195d Make auth method more resliant 2026-05-27 11:18:21 +00:00
JPVenson
36af7fa7bf Add lockhelper for UserManager 2026-05-26 20:10:56 +00:00
Jellyfin Release Bot
4b4b4cd94d Bump version to 10.11.10 2026-05-24 17:37:49 -04:00
Joshua M. Boniface
f2b358a3c8 Merge commit from fork
Fix GHSA-wwwm-px48-fpvq
2026-05-24 17:34:48 -04:00
Joshua M. Boniface
95600752a3 Merge commit from fork
Fix GHSA-jg92-mrxq-vv75
2026-05-24 17:34:08 -04:00
Joshua M. Boniface
7249b5744c Merge commit from fork
Fix GHSA-f47c-m7gr-q92j
2026-05-24 17:33:51 -04:00
Joshua M. Boniface
d8b0034a50 Merge pull request #15048 from theguymadmax/fix-cache-issue
Fix stale UserData cache
2026-05-24 12:30:47 -04:00
Bond-009
ed379a1882 Merge pull request #16906 from JPVenson/fix/UserManagerCollation
Fix/user manager collation
2026-05-24 15:52:43 +02:00
JPVenson
984e67c067 Move index creation to its own migration 2026-05-24 12:04:40 +00:00
JPVenson
eece62a90b Add unqiue contraint for NormalizedUsername 2026-05-24 11:04:01 +00:00
JPVenson
e5c34e7096 Fix non changing username creation 2026-05-23 17:46:39 +00:00
JPVenson
9526083523 Reorder setup user update 2026-05-23 15:28:30 +00:00
JPVenson
c4fb0285fc Fixed startup user renaming 2026-05-23 15:15:09 +00:00
JPVenson
620b7a2495 Remove obsolete supressions 2026-05-22 22:41:23 +00:00
JPVenson
ac9aa273ab Update gitignore 2026-05-22 21:34:51 +00:00
Shadowghost
72360ba292 Fix GHSA-f47c-m7gr-q92j 2026-05-22 22:32:48 +02:00
Shadowghost
706000cfce Add PathHelper 2026-05-22 22:26:20 +02:00
Shadowghost
734145ab98 Fix GHSA-jg92-mrxq-vv75 2026-05-22 22:24:44 +02:00
Shadowghost
89d32a9525 Add PathHelper 2026-05-22 22:24:30 +02:00
Shadowghost
19a35a6159 Fix GHSA-wwwm-px48-fpvq 2026-05-22 22:23:12 +02:00
JPVenson
8743c22551 Add tests 2026-05-22 18:55:01 +00:00
JPVenson
d1ba366f97 Remove 3rd stage of migration 2026-05-22 17:56:11 +00:00
JPVenson
999de06d6b Add 3 part migration for normalized Username 2026-05-22 17:31:45 +00:00
JPVenson
6435600a9c Fix changing of username 2026-05-22 17:06:23 +00:00
JPVenson
e52e448c30 Definitively fixed migration 2026-05-22 16:36:19 +00:00
JPVenson
f1137a9587 Update default function for migration 2026-05-22 14:38:19 +00:00
JPVenson
6de99306ec Add normalized username column 2026-05-22 10:42:00 +00:00
JPVenson
03ff69a6e1 Update gitignore 2026-05-22 09:19:20 +00:00
theguymadmax
94d0f7b1ac Apply review feedback 2026-05-21 19:11:34 -04:00
theguymadmax
d2df6adc16 Fix stale UserData cache 2025-10-20 12:46:07 -04:00
31 changed files with 4307 additions and 86 deletions

4
.gitignore vendored
View File

@@ -277,3 +277,7 @@ apiclient/generated
# Omnisharp crash logs # Omnisharp crash logs
mono_crash.*.json mono_crash.*.json
# Devcontainer temp files
.devcontainer/devcontainer-lock.json
dotnet/

View File

@@ -36,7 +36,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId> <PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -2,10 +2,12 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library; namespace Emby.Server.Implementations.Library;
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
/// </summary> /// </summary>
public class PathManager : IPathManager public class PathManager : IPathManager
{ {
private readonly ILogger<PathManager> _logger;
private readonly IServerConfigurationManager _config; private readonly IServerConfigurationManager _config;
private readonly IApplicationPaths _appPaths; private readonly IApplicationPaths _appPaths;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class. /// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger.</param>
/// <param name="config">The server configuration manager.</param> /// <param name="config">The server configuration manager.</param>
/// <param name="appPaths">The application paths.</param> /// <param name="appPaths">The application paths.</param>
public PathManager( public PathManager(
ILogger<PathManager> logger,
IServerConfigurationManager config, IServerConfigurationManager config,
IApplicationPaths appPaths) IApplicationPaths appPaths)
{ {
_logger = logger;
_config = config; _config = config;
_appPaths = appPaths; _appPaths = appPaths;
} }
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments"); private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc /> /// <inheritdoc />
public string GetAttachmentPath(string mediaSourceId, string fileName) public string? GetAttachmentPath(string mediaSourceId, string fileName)
{ {
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName); var safeName = PathHelper.GetSafeLeafFileName(fileName);
if (safeName is null)
{
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
return null;
}
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
} }
/// <inheritdoc /> /// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item) public UserItemData GetUserData(User user, BaseItem item)
{ {
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData() var cacheKey = GetCacheKey(user.InternalId, item.Id);
{ return _cache.GetOrAdd(
Key = item.GetUserDataKeys()[0], cacheKey,
}; (k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
{
Key = i.GetUserDataKeys()[0],
},
item);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -142,13 +142,15 @@ public class StartupController : BaseJellyfinApiController
return BadRequest("Password must not be empty"); return BadRequest("Password must not be empty");
} }
if (startupUserDto.Name is not null)
{
user.Username = startupUserDto.Name;
}
await _userManager.UpdateUserAsync(user).ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
#pragma warning disable CA1309 // Use ordinal string comparison
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
{
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
}
#pragma warning restore CA1309 // Use ordinal string comparison
if (!string.IsNullOrEmpty(startupUserDto.Password)) if (!string.IsNullOrEmpty(startupUserDto.Password))
{ {
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false); await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);

View File

@@ -18,7 +18,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId> <PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,4 +1,3 @@
#pragma warning disable CA1307
#pragma warning disable RS0030 // Do not use banned APIs #pragma warning disable RS0030 // Do not use banned APIs
using System; using System;
@@ -52,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly AsyncKeyedLocker<Guid> _userLock = new(); private readonly LockHelper _userLock = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class. /// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -162,12 +161,8 @@ namespace Jellyfin.Server.Implementations.Users
using var dbContext = _dbProvider.CreateDbContext(); using var dbContext = _dbProvider.CreateDbContext();
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
return UserQuery(dbContext) return UserQuery(dbContext)
.FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper()); .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
@@ -188,10 +183,8 @@ namespace Jellyfin.Server.Implementations.Users
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId) .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
.ConfigureAwait(false)) .ConfigureAwait(false))
{ {
throw new ArgumentException(string.Format( throw new ArgumentException(string.Format(
@@ -199,8 +192,6 @@ namespace Jellyfin.Server.Implementations.Users
"A user with the name '{0}' already exists.", "A user with the name '{0}' already exists.",
newName)); newName));
} }
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user = await UserQuery(dbContext) user = await UserQuery(dbContext)
@@ -210,6 +201,7 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(userId)); ?? throw new ResourceNotFoundException(nameof(userId));
user.Username = newName; user.Username = newName;
user.NormalizedUsername = newName.ToUpperInvariant();
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
} }
} }
@@ -224,7 +216,58 @@ namespace Jellyfin.Server.Implementations.Users
{ {
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{ {
await UpdateUserInternalAsync(user).ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
// TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
var dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == user.Id)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
dbUser.Permissions.Clear();
foreach (var permission in user.Permissions)
{
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
}
dbUser.Preferences.Clear();
foreach (var preference in user.Preferences)
{
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
}
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
{
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
}
if (user.ProfileImage is null)
{
if (dbUser.ProfileImage is not null)
{
dbContext.Remove(dbUser.ProfileImage);
dbUser.ProfileImage = null;
}
}
else if (dbUser.ProfileImage is null)
{
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
{
LastModified = user.ProfileImage.LastModified
};
}
else
{
dbUser.ProfileImage.Path = user.ProfileImage.Path;
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
}
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
} }
} }
@@ -259,10 +302,8 @@ namespace Jellyfin.Server.Implementations.Users
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == name.ToUpper()) .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
.ConfigureAwait(false)) .ConfigureAwait(false))
{ {
throw new ArgumentException(string.Format( throw new ArgumentException(string.Format(
@@ -270,8 +311,6 @@ namespace Jellyfin.Server.Implementations.Users
"A user with the name '{0}' already exists.", "A user with the name '{0}' already exists.",
name)); name));
} }
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
@@ -472,12 +511,14 @@ namespace Jellyfin.Server.Implementations.Users
var user = GetUserByName(username); var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{ {
using var dbContext = _dbProvider.CreateDbContext();
// Reload the user now that we hold the lock so the RowVersion is current. // Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another // GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load. // write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null) if (user is not null)
{ {
user = GetUserById(user.Id) ?? user; user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
} }
var authResult = await AuthenticateLocalUser(username, password, user) var authResult = await AuthenticateLocalUser(username, password, user)
@@ -485,6 +526,13 @@ namespace Jellyfin.Server.Implementations.Users
var authenticationProvider = authResult.AuthenticationProvider; var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success; success = authResult.Success;
if (success && user is not null)
{
// refresh the user if the auth provider might have updated it in the auth method.
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
}
if (user is null) if (user is null)
{ {
string updatedUsername = authResult.Username; string updatedUsername = authResult.Username;
@@ -498,11 +546,16 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again // Search the database for the user again
// the authentication provider might have created it // the authentication provider might have created it
user = GetUserByName(username); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{ {
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
} }
} }
@@ -513,8 +566,10 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{ {
user.AuthenticationProviderId = providerId; await dbContext.Users
await UpdateUserInternalAsync(user).ConfigureAwait(false); .Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
.ConfigureAwait(false);
} }
} }
@@ -561,16 +616,42 @@ namespace Jellyfin.Server.Implementations.Users
{ {
if (isUserSession) if (isUserSession)
{ {
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; var date = DateTime.UtcNow;
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
} }
user.InvalidLoginAttemptCount = 0; await dbContext.Users
await UpdateUserInternalAsync(user).ConfigureAwait(false); .Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
.ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
} }
else else
{ {
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); user.InvalidLoginAttemptCount++;
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await dbContext.Users
.Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
.ConfigureAwait(false);
_logger.LogInformation( _logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).", "Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username, user.Username,
@@ -945,32 +1026,6 @@ namespace Jellyfin.Server.Implementations.Users
} }
} }
private async Task IncrementInvalidLoginAttemptCount(User user)
{
user.InvalidLoginAttemptCount++;
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
_logger.LogWarning(
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
user.Username,
user.InvalidLoginAttemptCount);
}
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
private async Task UpdateUserInternalAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{ {
dbContext.Users.Attach(user); dbContext.Users.Attach(user);
@@ -996,5 +1051,70 @@ namespace Jellyfin.Server.Implementations.Users
_userLock.Dispose(); _userLock.Dispose();
} }
} }
internal sealed class LockHelper : IDisposable
{
private readonly AsyncKeyedLocker<Guid> _userLock = new();
private bool _disposed;
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
public bool ShouldLock()
{
return IsNestedLock.Value == 0;
}
public ValueTask<IDisposable> LockAsync(Guid key)
{
ThrowIfDisposed();
var isNested = LockHelper.IsNestedLock.Value != 0;
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
if (isNested)
{
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
}
return AcquireLockAsync(key);
}
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
{
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
return new LockHandle { Parent = lockHandle };
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_userLock.Dispose();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class LockHandle : IDisposable
{
public required IDisposable? Parent { get; init; }
public void Dispose()
{
Parent?.Dispose();
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
if (LockHelper.IsNestedLock.Value < 0)
{
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
}
}
}
}
} }
} }

View File

@@ -0,0 +1,44 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Part 2 Migration for NormalisedUsername.
/// </summary>
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
#pragma warning disable SA1649 // File name should match first type name
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
#pragma warning restore SA1649 // File name should match first type name
{
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
/// </summary>
/// <param name="contextFactory">Db Context factory.</param>
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var user in users)
{
user.NormalizedUsername = user.Username.ToUpperInvariant();
}
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -181,7 +181,9 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
} }
} }
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); var attachmentIndexName = attachmentIndex.ToString(CultureInfo.InvariantCulture);
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndexName)
?? _pathManager.GetAttachmentPath(itemIdString, attachmentIndexName)!;
if (File.Exists(newAttachmentPath)) if (File.Exists(newAttachmentPath))
{ {
File.Delete(oldAttachmentPath); File.Delete(oldAttachmentPath);

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId> <PackageId>Jellyfin.Common</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent namespace MediaBrowser.Controller.ClientEvent
{ {
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc /> /// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents) public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{ {
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
{
throw new ArgumentException("Path resolved to filename not in log directory");
}
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false)) await using (fileStream.ConfigureAwait(false))
{ {

View File

@@ -37,8 +37,8 @@ public interface IPathManager
/// </summary> /// </summary>
/// <param name="mediaSourceId">The media source id.</param> /// <param name="mediaSourceId">The media source id.</param>
/// <param name="fileName">The attachmentFileName index.</param> /// <param name="fileName">The attachmentFileName index.</param>
/// <returns>The absolute path.</returns> /// <returns>The absolute path, or <c>null</c> if <paramref name="fileName"/> cannot be reduced to a safe leaf name.</returns>
public string GetAttachmentPath(string mediaSourceId, string fileName); public string? GetAttachmentPath(string mediaSourceId, string fileName);
/// <summary> /// <summary>
/// Gets the path to the attachment folder. /// Gets the path to the attachment folder.

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId> <PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -1305,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(canvasArgs); arg.Append(canvasArgs);
} }
arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"');
} }
if (state.AudioStream is not null && state.AudioStream.IsExternal) if (state.AudioStream is not null && state.AudioStream.IsExternal)
@@ -1317,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
arg.Append(' ').Append(seekAudioParam); arg.Append(' ').Append(seekAudioParam);
} }
arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
} }
// Disable auto inserted SW scaler for HW decoders in case of changed resolution. // Disable auto inserted SW scaler for HW decoders in case of changed resolution.

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
@@ -98,9 +99,21 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaSourceInfo mediaSource, MediaSourceInfo mediaSource,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName) var hasUnsafeFileName = mediaSource.MediaAttachments.Any(a => !IsSafeBulkAttachmentFileName(a.FileName));
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); var isMatroskaSubtitles = inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase);
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) if (hasUnsafeFileName && isMatroskaSubtitles)
{
_logger.LogError(
"Refusing attachment extraction for .mks input {InputFile}: an attachment FileName tag is not a safe leaf name.",
inputFile);
throw new InvalidOperationException(
string.Format(
CultureInfo.InvariantCulture,
"Refusing attachment extraction for .mks input {0}: unsafe attachment FileName tag.",
inputFile));
}
if (hasUnsafeFileName)
{ {
foreach (var attachment in mediaSource.MediaAttachments) foreach (var attachment in mediaSource.MediaAttachments)
{ {
@@ -241,7 +254,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id); var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false)) using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{ {
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture)); var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath)) if (!File.Exists(attachmentPath))
{ {
await ExtractAttachmentInternal( await ExtractAttachmentInternal(
@@ -341,6 +356,27 @@ namespace MediaBrowser.MediaEncoding.Attachments
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath); _logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
} }
/// <summary>
/// Decides whether an attachment FILENAME tag is safe to feed into ffmpeg's
/// bulk <c>-dump_attachment:t ""</c> mode, which writes each attachment using
/// its filename tag verbatim relative to the working directory. Anything that
/// could escape the working directory - path separators, "..", or empty
/// leaves - is rerouted to the one-by-one path where the filename is
/// sanitised via <see cref="IPathManager.GetAttachmentPath"/>.
/// </summary>
private static bool IsSafeBulkAttachmentFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return true;
}
// PathHelper.GetSafeLeafFileName collapses to the leaf component;
// the bulk path is only safe when the supplied name already _was_
// that leaf (no separators, no "."/"..").
return PathHelper.GetSafeLeafFileName(fileName) == fileName;
}
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {

View File

@@ -19,6 +19,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO; using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
@@ -372,7 +373,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
CreateNoWindow = true, CreateNoWindow = true,
UseShellExecute = false, UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath, FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)),
WindowStyle = ProcessWindowStyle.Hidden, WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false ErrorDialog = false
}, },

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId> <PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{ {
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName)); var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path."); // TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal)) if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{ {
savePaths.Add(mediaFolderPath); savePaths.Add(mediaFolderPath);
} }
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName)); var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path."); // TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal)) if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{ {
savePaths.Add(internalPath); savePaths.Add(internalPath);
} }

View File

@@ -1,4 +1,4 @@
using System.Reflection; using System.Reflection;
[assembly: AssemblyVersion("10.11.9")] [assembly: AssemblyVersion("10.11.11")]
[assembly: AssemblyFileVersion("10.11.9")] [assembly: AssemblyFileVersion("10.11.11")]

View File

@@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId); ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
Username = username; Username = username;
NormalizedUsername = username.ToUpperInvariant();
AuthenticationProviderId = authenticationProviderId; AuthenticationProviderId = authenticationProviderId;
PasswordResetProviderId = passwordResetProviderId; PasswordResetProviderId = passwordResetProviderId;
@@ -73,6 +74,16 @@ namespace Jellyfin.Database.Implementations.Entities
[StringLength(255)] [StringLength(255)]
public string Username { get; set; } public string Username { get; set; }
/// <summary>
/// Gets or sets the user's normalized name.
/// </summary>
/// <remarks>
/// Required, Max length = 255.
/// </remarks>
[MaxLength(255)]
[StringLength(255)]
public string NormalizedUsername { get; set; }
/// <summary> /// <summary>
/// Gets or sets the user's password, or <c>null</c> if none is set. /// Gets or sets the user's password, or <c>null</c> if none is set.
/// </summary> /// </summary>

View File

@@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
builder builder
.HasIndex(entity => entity.Username) .HasIndex(entity => entity.Username)
.IsUnique(); .IsUnique();
builder
.HasIndex(entity => entity.NormalizedUsername)
.IsUnique();
} }
} }
} }

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddNormalizedUsername : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// this is the first part of the migration. Add the column.
migrationBuilder.AddColumn<string>(
name: "NormalizedUsername",
table: "Users",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizedUsername",
table: "Users");
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddUniqueNormalizedUsernameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedUsername",
table: "Users",
column: "NormalizedUsername",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_NormalizedUsername",
table: "Users");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); modelBuilder.HasAnnotation("ProductVersion", "9.0.11");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{ {
@@ -1303,6 +1303,11 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool>("MustUpdatePassword") b.Property<bool>("MustUpdatePassword")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Password") b.Property<string>("Password")
.HasMaxLength(65535) .HasMaxLength(65535)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -1345,6 +1350,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique(); .IsUnique();

View File

@@ -15,7 +15,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId> <PackageId>Jellyfin.Extensions</PackageId>
<VersionPrefix>10.11.9</VersionPrefix> <VersionPrefix>10.11.11</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -0,0 +1,77 @@
using System;
using System.IO;
namespace Jellyfin.Extensions;
/// <summary>
/// Helpers for safely composing filesystem paths from untrusted input.
/// </summary>
/// <remarks>
/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
/// any code that joins a trusted directory with an externally-supplied name:
/// it neither normalises <c>..</c> nor rejects a rooted second argument
/// (a rooted second arg silently discards the first). Use the helpers below
/// any time the name comes from media metadata, request input, archive
/// entries, or any other channel that can be influenced by a third party.
/// </remarks>
public static class PathHelper
{
/// <summary>
/// Reduces a possibly-untrusted file name to a safe leaf-only name with no
/// directory components.
/// </summary>
/// <param name="fileName">The candidate file name.</param>
/// <returns>
/// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
/// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
/// </returns>
public static string? GetSafeLeafFileName(string? fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var leaf = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(leaf) || leaf == "." || leaf == "..")
{
return null;
}
return leaf;
}
/// <summary>
/// Returns whether <paramref name="candidate"/> resolves to a path that
/// equals or is contained inside <paramref name="root"/>.
/// </summary>
/// <param name="root">The directory the candidate must remain inside.</param>
/// <param name="candidate">The candidate absolute or relative path.</param>
/// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
/// <remarks>
/// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
/// so <c>..</c> segments are collapsed before the comparison. The root is
/// compared with a trailing directory separator to prevent prefix
/// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
/// <c>/var/dataset</c>).
/// </remarks>
public static bool IsContainedIn(string root, string candidate)
{
ArgumentException.ThrowIfNullOrEmpty(root);
ArgumentException.ThrowIfNullOrEmpty(candidate);
var fullRoot = Path.GetFullPath(root);
var fullCandidate = Path.GetFullPath(candidate);
if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
{
return true;
}
var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
? fullRoot
: fullRoot + Path.DirectorySeparatorChar;
return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.ClientEvent;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests
{
public class ClientEventLoggerTests
{
[Theory]
[InlineData("../../../../etc/passwd", "1.0")]
[InlineData("..\\..\\windows\\system32", "1.0")]
[InlineData("normal-client", "../../../etc/passwd")]
[InlineData("/absolute/path", "1.0")]
public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
{
var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
Directory.CreateDirectory(logDir);
try
{
var paths = new Mock<IServerApplicationPaths>();
paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
var logger = new ClientEventLogger(paths.Object);
using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
Assert.True(File.Exists(resolved));
}
finally
{
Directory.Delete(logDir, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Server.Implementations.Users;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public class UserManagerLockHelperTests
{
[Fact]
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
Assert.True(helper.ShouldLock());
var outerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
var innerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
innerHandle.Dispose();
Assert.False(helper.ShouldLock());
outerHandle.Dispose();
Assert.True(helper.ShouldLock());
}
[Fact]
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var secondEntered = false;
var firstTask = Task.Run(async () =>
{
using var firstHandle = await helper.LockAsync(key);
firstAcquired.SetResult(true);
await releaseFirst.Task;
});
await firstAcquired.Task;
var secondTask = Task.Run(async () =>
{
using var secondHandle = await helper.LockAsync(key);
secondEntered = true;
});
await Task.Delay(100);
Assert.False(secondEntered);
releaseFirst.SetResult(true);
await Task.WhenAll(firstTask, secondTask);
Assert.True(secondEntered);
}
[Fact]
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
}
[Fact]
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
var ex = Record.Exception(() => helper.Dispose());
Assert.Null(ex);
}
}
}

View File

@@ -0,0 +1,240 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Cryptography;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public sealed class UserManagerNormalizedUsernameTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly UserManager _userManager;
public UserManagerNormalizedUsernameTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
// Create the schema
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(CreateDbContext);
var cryptoProvider = new Mock<ICryptoProvider>();
var configManager = new Mock<IServerConfigurationManager>();
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
var appHost = new Mock<IApplicationHost>();
var defaultAuthProvider = new DefaultAuthenticationProvider(
NullLogger<DefaultAuthenticationProvider>.Instance,
cryptoProvider.Object);
var invalidAuthProvider = new InvalidAuthProvider();
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
configManager.Object,
appHost.Object);
_userManager = new UserManager(
factory.Object,
new NoopEventManager(),
new Mock<INetworkManager>().Object,
appHost.Object,
new Mock<IImageProcessor>().Object,
NullLogger<UserManager>.Instance,
configManager.Object,
new IPasswordResetProvider[] { defaultPasswordResetProvider },
new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
}
public void Dispose()
{
_userManager.Dispose();
_connection.Dispose();
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
// ----- GetUserByName tests -----
[Theory]
// German umlauts
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n
[InlineData("Ñoño", "ÑOÑO")]
// ASCII, invariant uppercase lookup
[InlineData("jellyfin", "JELLYFIN")]
// Turkish cedilla: invariant 'i' uppercases to 'I' (U+0049), not Turkish 'İ' (U+0130)
[InlineData("Çelebi", "ÇELEBI")]
public async Task GetUserByName_WithNonAsciiUsername_FindsUserByNormalizedName(
string username, string normalizedLookup)
{
await _userManager.CreateUserAsync(username);
var found = _userManager.GetUserByName(normalizedLookup);
Assert.NotNull(found);
Assert.Equal(username, found.Username);
}
[Theory]
// German umlaut, look up by both upper and lower case
[InlineData("münchen")]
// Spanish tilde-n
[InlineData("Ñoño")]
// lowercase 'i' — invariant ToUpperInvariant gives 'I', not Turkish 'İ'
[InlineData("ali")]
// mixed ASCII + umlaut
[InlineData("testüser")]
public async Task GetUserByName_WithVariousCase_FindsUserCaseInsensitively(string username)
{
await _userManager.CreateUserAsync(username);
var upperFound = _userManager.GetUserByName(username.ToUpperInvariant());
var lowerFound = _userManager.GetUserByName(username.ToLowerInvariant());
var exactFound = _userManager.GetUserByName(username);
Assert.NotNull(upperFound);
Assert.NotNull(lowerFound);
Assert.NotNull(exactFound);
}
[Theory]
[InlineData("nonexistent")]
// No user with NormalizedUsername = "MÜNCHEN" has been created
[InlineData("MÜNCHEN")]
public void GetUserByName_WhenUserDoesNotExist_ReturnsNull(string lookupName)
{
var result = _userManager.GetUserByName(lookupName);
Assert.Null(result);
}
// ----- CreateUserAsync duplicate detection tests -----
[Theory]
// German umlaut, case-swapped duplicate
[InlineData("münchen", "MÜNCHEN")]
// Spanish tilde-n, lowercase duplicate
[InlineData("Ñoño", "ñoño")]
// ASCII, uppercase duplicate
[InlineData("alice", "ALICE")]
// Turkish cedilla: "çelebi".ToUpperInvariant() == "ÇELEBI" == "ÇELEBI".ToUpperInvariant()
[InlineData("çelebi", "ÇELEBI")]
public async Task CreateUserAsync_WhenNormalizedNameAlreadyExists_ThrowsArgumentException(
string existingUsername, string duplicateUsername)
{
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.CreateUserAsync(duplicateUsername));
}
[Theory]
// Different non-ASCII names that do not collide after normalization
[InlineData("münchen", "münchen2")]
[InlineData("ali", "ali2")]
// Visually similar but different Unicode code points: ñ (U+00F1) vs n (U+006E)
[InlineData("noño", "nono")]
public async Task CreateUserAsync_WithDistinctNonAsciiUsernames_CreatesBothUsers(
string firstUsername, string secondUsername)
{
var first = await _userManager.CreateUserAsync(firstUsername);
var second = await _userManager.CreateUserAsync(secondUsername);
Assert.NotNull(first);
Assert.NotNull(second);
Assert.NotEqual(first.Id, second.Id);
}
// ----- RenameUser tests -----
[Theory]
// Rename to non-ASCII name
[InlineData("alice", "münchen")]
// Rename between similar non-ASCII and ASCII
[InlineData("müller", "mueller")]
// Contains 'i': invariant uppercase is always 'I', never Turkish 'İ'
[InlineData("ali", "ALI2")]
// Rename to Spanish tilde-n name
[InlineData("testuser", "Ñoño")]
public async Task RenameUser_SetsNormalizedUsernameToUpperInvariant(
string originalName, string newName)
{
var user = await _userManager.CreateUserAsync(originalName);
await _userManager.RenameUser(user.Id, originalName, newName);
var renamed = _userManager.GetUserById(user.Id);
Assert.NotNull(renamed);
Assert.Equal(newName, renamed.Username);
Assert.Equal(newName.ToUpperInvariant(), renamed.NormalizedUsername);
}
[Theory]
// Same name different case: NormalizedUsername already taken
[InlineData("münchen", "MÜNCHEN")]
// Spanish, lowercase conflicts with existing uppercase-normalised entry
[InlineData("Ñoño", "ñoño")]
// ASCII, capitalised conflict
[InlineData("alice", "Alice")]
// Mixed ASCII + umlaut
[InlineData("testüser", "TESTÜSER")]
public async Task RenameUser_WhenNormalizedNameConflictsWithExistingUser_ThrowsArgumentException(
string existingUsername, string conflictingNewName)
{
var targetUser = await _userManager.CreateUserAsync("renametarget");
await _userManager.CreateUserAsync(existingUsername);
await Assert.ThrowsAsync<ArgumentException>(
() => _userManager.RenameUser(targetUser.Id, "renametarget", conflictingNewName));
}
private sealed class NoopEventManager : IEventManager
{
public void Publish<T>(T eventArgs)
where T : EventArgs
{
}
public Task PublishAsync<T>(T eventArgs)
where T : EventArgs
=> Task.CompletedTask;
}
}
}