Merge pull request #9 from Franciskid/security/harden-redemption-and-expiry

Harden redemption, expiry limits, token storage and guest sign-in
Cette révision appartient à :
Francois CB
2026-07-26 20:44:42 +02:00
révisé par GitHub
révision e419cbbce4
10 fichiers modifiés avec 205 ajouts et 441 suppressions
+3 -3
Voir le fichier
@@ -190,10 +190,10 @@ public sealed class ShareLinksController : ControllerBase
return BadRequest(new { error = "Expiry must be positive." }); return BadRequest(new { error = "Expiry must be positive." });
} }
var effectiveMaxExpiryHours = Math.Max(config.MaxExpiryHours, 720); var maxExpiryHours = config.MaxExpiryHours > 0 ? config.MaxExpiryHours : 720;
if (expiryHours > effectiveMaxExpiryHours) if (expiryHours > maxExpiryHours)
{ {
return BadRequest(new { error = $"Expiry exceeds the configured maximum of {effectiveMaxExpiryHours} hours." }); return BadRequest(new { error = $"Expiry exceeds the configured maximum of {maxExpiryHours} hours." });
} }
var item = _libraryManager.GetItemById(itemId); var item = _libraryManager.GetItemById(itemId);
+2
Voir le fichier
@@ -3,6 +3,7 @@ using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage; using Jellyfin.Plugin.ShareLinks.Storage;
using Jellyfin.Plugin.ShareLinks.Web; using Jellyfin.Plugin.ShareLinks.Web;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -24,6 +25,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<ShareTokenService>(); serviceCollection.AddSingleton<ShareTokenService>();
serviceCollection.AddSingleton<ItemTagService>(); serviceCollection.AddSingleton<ItemTagService>();
serviceCollection.AddSingleton<JellyfinGuestUserService>(); serviceCollection.AddSingleton<JellyfinGuestUserService>();
serviceCollection.AddSingleton<IAuthenticationProvider, GuestAuthenticationProvider>();
serviceCollection.AddSingleton<ShareLinkCreationService>(); serviceCollection.AddSingleton<ShareLinkCreationService>();
serviceCollection.AddSingleton<ShareLinkRedemptionService>(); serviceCollection.AddSingleton<ShareLinkRedemptionService>();
serviceCollection.AddSingleton<ShareLinkCleanupService>(); serviceCollection.AddSingleton<ShareLinkCleanupService>();
+53
Voir le fichier
@@ -0,0 +1,53 @@
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Authentication;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services;
/// <summary>
/// The authentication provider assigned to ShareLinks guest accounts. It refuses
/// every interactive sign-in, so a guest account cannot be used on the normal
/// login page even if its name and password were to leak. Guest sessions are
/// minted server side through <c>ISessionManager.AuthenticateDirect</c>, which
/// does not enforce a password and so never reaches a provider at all.
/// </summary>
public sealed class GuestAuthenticationProvider : IAuthenticationProvider
{
private readonly ILogger<GuestAuthenticationProvider> _logger;
/// <summary>Initializes a new instance of the <see cref="GuestAuthenticationProvider"/> class.</summary>
public GuestAuthenticationProvider(ILogger<GuestAuthenticationProvider> logger)
{
_logger = logger;
}
/// <summary>
/// Gets the value Jellyfin stores on a user to select this provider. Jellyfin
/// matches it against the provider's full type name.
/// </summary>
public static string ProviderId => typeof(GuestAuthenticationProvider).FullName!;
/// <inheritdoc />
public string Name => "ShareLinks guest accounts (blocks sign-in)";
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public Task<ProviderAuthenticationResult> Authenticate(string username, string password)
{
_logger.LogWarning("ShareLinks: refused an interactive sign-in attempt for guest account {UserName}.", username);
return Task.FromException<ProviderAuthenticationResult>(
new AuthenticationException("ShareLinks guest accounts cannot sign in interactively."));
}
/// <summary>
/// Reports the account as having a password so nothing offers it as a
/// passwordless login.
/// </summary>
public bool HasPassword(User user) => true;
/// <inheritdoc />
public Task ChangePassword(User user, string newPassword) => Task.CompletedTask;
}
+2 -41
Voir le fichier
@@ -1,12 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Services; namespace Jellyfin.Plugin.ShareLinks.Services;
@@ -157,46 +157,7 @@ public sealed class ItemTagService
private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken) private async Task PersistAsync(BaseItem item, CancellationToken cancellationToken)
{ {
var method = _libraryManager.GetType()
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(candidate =>
{
if (!string.Equals(candidate.Name, "UpdateItemAsync", StringComparison.Ordinal))
{
return false;
}
var parameters = candidate.GetParameters();
return parameters.Length == 4
&& typeof(BaseItem).IsAssignableFrom(parameters[0].ParameterType)
&& typeof(BaseItem).IsAssignableFrom(parameters[1].ParameterType)
&& parameters[3].ParameterType == typeof(CancellationToken);
});
if (method is null)
{
throw new MissingMethodException(_libraryManager.GetType().FullName, "UpdateItemAsync");
}
var parametersInfo = method.GetParameters();
var updateReason = parametersInfo[2].ParameterType.IsEnum
? Enum.ToObject(parametersInfo[2].ParameterType, 0)
: 0;
var parent = item.DisplayParent ?? item; var parent = item.DisplayParent ?? item;
var task = method.Invoke(_libraryManager, new object?[] await _libraryManager.UpdateItemAsync(item, parent, ItemUpdateType.None, cancellationToken).ConfigureAwait(false);
{
item,
parent,
updateReason,
cancellationToken
}) as Task;
if (task is null)
{
throw new InvalidOperationException("UpdateItemAsync did not return a task.");
}
await task.ConfigureAwait(false);
} }
} }
+73 -383
Voir le fichier
@@ -1,12 +1,9 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Configuration; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Models;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Users; using MediaBrowser.Model.Users;
@@ -43,7 +40,7 @@ public sealed class JellyfinGuestUserService
} }
/// <summary>Ensures the temporary guest user exists and has the correct policy and password.</summary> /// <summary>Ensures the temporary guest user exists and has the correct policy and password.</summary>
public async Task<dynamic> EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken) public async Task<User> EnsureGuestUserAsync(ShareLinkRecord record, string password, CancellationToken cancellationToken)
{ {
if (record is null) if (record is null)
{ {
@@ -55,6 +52,8 @@ public sealed class JellyfinGuestUserService
throw new ArgumentException("Password cannot be empty.", nameof(password)); throw new ArgumentException("Password cannot be empty.", nameof(password));
} }
cancellationToken.ThrowIfCancellationRequested();
var username = record.GuestUserName; var username = record.GuestUserName;
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
{ {
@@ -62,42 +61,27 @@ public sealed class JellyfinGuestUserService
record.GuestUserName = username; record.GuestUserName = username;
} }
object? user = _userManager.GetUserByName(username); var user = _userManager.GetUserByName(username);
if (user is null) if (user is null)
{ {
user = await InvokeUserManagerAsync<object?>( user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
"create user",
cancellationToken,
new InvocationCandidate("CreateUserAsync", new object?[] { username }),
new InvocationCandidate("CreateUser", new object?[] { username }))
.ConfigureAwait(false) ?? _userManager.GetUserByName(username);
if (user is null) if (user is null)
{ {
throw new InvalidOperationException($"Unable to create temporary guest user '{username}'."); throw new InvalidOperationException($"Unable to create temporary guest user '{username}'.");
} }
} }
var existingUserId = GetUserId(user); // The password must be set before the policy update: UpdatePolicyAsync bumps the
if (existingUserId != Guid.Empty) // user's EF concurrency token server side, and ChangePassword with a stale instance
{ // then throws DbUpdateConcurrencyException. The password is only a fallback - the
user = _userManager.GetUserById(existingUserId) ?? user; // policy hands the account to GuestAuthenticationProvider, which refuses every
} // interactive sign-in - but it means the account is never reachable with a blank
// password either.
await _userManager.ChangePassword(user, password).ConfigureAwait(false);
await ApplyPolicyAsync(user, record, disabled: false).ConfigureAwait(false);
// The password must be changed before the policy update: UpdatePolicyAsync bumps the user = _userManager.GetUserById(user.Id) ?? user;
// user's EF concurrency token server-side, and ChangePassword with a stale instance _logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", user.Username, record.Id);
// throws DbUpdateConcurrencyException.
await ChangePasswordAsync(user, password, cancellationToken).ConfigureAwait(false);
await ApplyPolicyAsync(user, record, disabled: false, cancellationToken).ConfigureAwait(false);
var userId = GetUserId(user);
if (userId == Guid.Empty)
{
throw new InvalidOperationException("ShareLinks: created guest user did not expose a valid Id.");
}
user = _userManager.GetUserById(userId) ?? user;
_logger.LogInformation("ShareLinks: ensured guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id);
return user; return user;
} }
@@ -110,13 +94,14 @@ public sealed class JellyfinGuestUserService
return; return;
} }
cancellationToken.ThrowIfCancellationRequested();
try try
{ {
await ApplyPolicyAsync(user, record, disabled: true, cancellationToken).ConfigureAwait(false); await ApplyPolicyAsync(user, record, disabled: true).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogWarning(ex, "ShareLinks: failed to disable guest user {UserName} for record {RecordId}.", user.Username, record.Id);
} }
} }
@@ -129,19 +114,20 @@ public sealed class JellyfinGuestUserService
return; return;
} }
cancellationToken.ThrowIfCancellationRequested();
try try
{ {
await DeleteUserAsync(user, cancellationToken).ConfigureAwait(false); await _userManager.DeleteUserAsync(user.Id).ConfigureAwait(false);
_logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogInformation("ShareLinks: deleted guest user {UserName} for record {RecordId}.", user.Username, record.Id);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", GetUserName(user), record.Id); _logger.LogWarning(ex, "ShareLinks: failed to delete guest user {UserName} for record {RecordId}.", user.Username, record.Id);
throw; throw;
} }
} }
private object? FindRecordUser(ShareLinkRecord record) private User? FindRecordUser(ShareLinkRecord record)
{ {
if (record.GuestUserId.HasValue) if (record.GuestUserId.HasValue)
{ {
@@ -152,358 +138,62 @@ public sealed class JellyfinGuestUserService
} }
} }
if (!string.IsNullOrWhiteSpace(record.GuestUserName)) return string.IsNullOrWhiteSpace(record.GuestUserName)
{ ? null
return _userManager.GetUserByName(record.GuestUserName); : _userManager.GetUserByName(record.GuestUserName);
} }
return null; private async Task ApplyPolicyAsync(User user, ShareLinkRecord record, bool disabled)
}
private async Task ApplyPolicyAsync(object user, ShareLinkRecord record, bool disabled, CancellationToken cancellationToken)
{ {
var config = Plugin.Instance!.Configuration; var config = Plugin.Instance!.Configuration;
var policy = new UserPolicy(); var policy = new UserPolicy
SetPolicyValue(policy, "AuthenticationProviderId", GetUserValue(user, "AuthenticationProviderId"));
SetPolicyValue(policy, "PasswordResetProviderId", GetUserValue(user, "PasswordResetProviderId"));
SetPolicyValue(policy, "AllowedTags", string.IsNullOrWhiteSpace(record.AllowedTag) ? Array.Empty<string>() : new[] { record.AllowedTag! });
SetPolicyValue(policy, "BlockedTags", Array.Empty<string>());
SetPolicyValue(policy, "IsAdministrator", false);
SetPolicyValue(policy, "IsHidden", true);
SetPolicyValue(policy, "IsDisabled", disabled);
SetPolicyValue(policy, "EnableCollectionManagement", false);
SetPolicyValue(policy, "EnableSubtitleManagement", false);
SetPolicyValue(policy, "EnableLyricManagement", false);
SetPolicyValue(policy, "EnableUserPreferenceAccess", false);
SetPolicyValue(policy, "EnableSharedDeviceControl", false);
SetPolicyValue(policy, "EnableRemoteAccess", true);
SetPolicyValue(policy, "EnableRemoteControlOfOtherUsers", false);
SetPolicyValue(policy, "EnableLiveTvManagement", false);
SetPolicyValue(policy, "EnableLiveTvAccess", false);
SetPolicyValue(policy, "EnableMediaPlayback", true);
SetPolicyValue(policy, "EnableAudioPlaybackTranscoding", config.AllowTranscoding);
SetPolicyValue(policy, "EnableVideoPlaybackTranscoding", config.AllowTranscoding);
SetPolicyValue(policy, "EnablePlaybackRemuxing", config.AllowRemuxing);
SetPolicyValue(policy, "ForceRemoteSourceTranscoding", false);
SetPolicyValue(policy, "EnableContentDeletion", false);
SetPolicyValue(policy, "EnableContentDeletionFromFolders", Array.Empty<string>());
SetPolicyValue(policy, "EnableContentDownloading", false);
SetPolicyValue(policy, "EnableSyncTranscoding", false);
SetPolicyValue(policy, "EnableMediaConversion", false);
SetPolicyValue(policy, "EnableAllChannels", false);
SetPolicyValue(policy, "EnabledChannels", Array.Empty<Guid>());
SetPolicyValue(policy, "EnableAllDevices", true);
SetPolicyValue(policy, "EnabledDevices", Array.Empty<string>());
SetPolicyValue(policy, "EnableAllFolders", true);
SetPolicyValue(policy, "EnabledFolders", Array.Empty<Guid>());
SetPolicyValue(policy, "EnablePublicSharing", false);
SetPolicyValue(policy, "LoginAttemptsBeforeLockout", -1);
SetPolicyValue(policy, "MaxActiveSessions", 1);
SetPolicyValue(policy, "BlockUnratedItems", Array.Empty<Jellyfin.Data.Enums.UnratedItem>());
await InvokeUserManagerAsync<object?>(
"update policy",
cancellationToken,
new InvocationCandidate("UpdatePolicyAsync", new object?[] { GetUserId(user), policy }),
new InvocationCandidate("UpdatePolicyAsync", new object?[] { user, policy }),
new InvocationCandidate("UpdatePolicy", new object?[] { GetUserId(user), policy }),
new InvocationCandidate("UpdatePolicy", new object?[] { user, policy }))
.ConfigureAwait(false);
}
private async Task ChangePasswordAsync(object user, string password, CancellationToken cancellationToken)
{ {
await InvokeUserManagerAsync<object?>( // Hand the account to a provider that refuses interactive sign-in. If the
"change password", // plugin is ever disabled the id stops resolving and Jellyfin falls back to
cancellationToken, // its own InvalidAuthProvider, which also refuses, so this fails closed.
new InvocationCandidate("ChangePasswordAsync", new object?[] { user, password }), AuthenticationProviderId = GuestAuthenticationProvider.ProviderId,
new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), password }), PasswordResetProviderId = user.PasswordResetProviderId,
new InvocationCandidate("ChangePasswordAsync", new object?[] { user, string.Empty, password }), AllowedTags = string.IsNullOrWhiteSpace(record.AllowedTag)
new InvocationCandidate("ChangePasswordAsync", new object?[] { GetUserId(user), string.Empty, password }), ? Array.Empty<string>()
new InvocationCandidate("ChangePassword", new object?[] { user, password }), : new[] { record.AllowedTag! },
new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), password }), BlockedTags = Array.Empty<string>(),
new InvocationCandidate("ChangePassword", new object?[] { user, string.Empty, password }), IsAdministrator = false,
new InvocationCandidate("ChangePassword", new object?[] { GetUserId(user), string.Empty, password })) IsHidden = true,
.ConfigureAwait(false); IsDisabled = disabled,
} EnableCollectionManagement = false,
EnableSubtitleManagement = false,
EnableLyricManagement = false,
EnableUserPreferenceAccess = false,
EnableSharedDeviceControl = false,
EnableRemoteAccess = true,
EnableRemoteControlOfOtherUsers = false,
EnableLiveTvManagement = false,
EnableLiveTvAccess = false,
EnableMediaPlayback = true,
EnableAudioPlaybackTranscoding = config.AllowTranscoding,
EnableVideoPlaybackTranscoding = config.AllowTranscoding,
EnablePlaybackRemuxing = config.AllowRemuxing,
ForceRemoteSourceTranscoding = false,
EnableContentDeletion = false,
EnableContentDeletionFromFolders = Array.Empty<string>(),
EnableContentDownloading = false,
EnableSyncTranscoding = false,
EnableMediaConversion = false,
EnableAllChannels = false,
EnabledChannels = Array.Empty<Guid>(),
EnableAllDevices = true,
EnabledDevices = Array.Empty<string>(),
EnableAllFolders = true,
EnabledFolders = Array.Empty<Guid>(),
EnablePublicSharing = false,
LoginAttemptsBeforeLockout = -1,
MaxActiveSessions = 1,
BlockUnratedItems = Array.Empty<UnratedItem>()
};
private async Task DeleteUserAsync(object user, CancellationToken cancellationToken) await _userManager.UpdatePolicyAsync(user.Id, policy).ConfigureAwait(false);
{
await InvokeUserManagerAsync<object?>(
"delete user",
cancellationToken,
new InvocationCandidate("DeleteUserAsync", new object?[] { GetUserId(user) }),
new InvocationCandidate("DeleteUserAsync", new object?[] { user }),
new InvocationCandidate("DeleteUser", new object?[] { GetUserId(user) }),
new InvocationCandidate("DeleteUser", new object?[] { user }))
.ConfigureAwait(false);
} }
private async Task<T?> InvokeUserManagerAsync<T>(
string operationName,
CancellationToken cancellationToken,
params InvocationCandidate[] candidates)
{
var managerType = _userManager.GetType();
var triedVariants = new List<string>();
foreach (var candidate in candidates)
{
var methods = managerType.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(method => string.Equals(method.Name, candidate.MethodName, StringComparison.Ordinal));
var matchedMethod = false;
foreach (var method in methods)
{
if (!TryBindArguments(method, candidate.Arguments, cancellationToken, out var invocationArguments))
{
continue;
}
matchedMethod = true;
var invocation = method.Invoke(_userManager, invocationArguments);
if (invocation is Task task)
{
await task.WaitAsync(cancellationToken).ConfigureAwait(false);
var resultProperty = invocation.GetType().GetProperty("Result", BindingFlags.Instance | BindingFlags.Public);
if (resultProperty is null)
{
return default;
}
var result = resultProperty.GetValue(invocation);
if (result is null)
{
return default;
}
if (result is T typedResult)
{
return typedResult;
}
throw new InvalidOperationException(
$"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {result.GetType().FullName} for {operationName}.");
}
if (invocation is T directResult)
{
return directResult;
}
if (invocation is null)
{
return default;
}
throw new InvalidOperationException(
$"ShareLinks: {managerType.FullName}.{candidate.MethodName} returned incompatible result type {invocation.GetType().FullName} for {operationName}.");
}
if (!matchedMethod)
{
triedVariants.Add($"{candidate.MethodName}({DescribeArguments(candidate.Arguments)})");
}
}
_logger.LogWarning(
"ShareLinks: {UserManagerType} does not expose a compatible {Operation} variant. Tried {Variants}.",
managerType.FullName,
operationName,
string.Join("; ", triedVariants));
throw new MissingMethodException(managerType.FullName, operationName);
}
private static string DescribeArguments(object?[] arguments)
{
return string.Join(", ", arguments.Select(argument => argument?.GetType().Name ?? "null"));
}
private static bool TryBindArguments(MethodInfo method, object?[] suppliedArguments, CancellationToken cancellationToken, out object?[] invocationArguments)
{
var parameters = method.GetParameters();
if (suppliedArguments.Length > parameters.Length)
{
invocationArguments = Array.Empty<object?>();
return false;
}
invocationArguments = new object?[parameters.Length];
for (var index = 0; index < suppliedArguments.Length; index++)
{
if (!TryConvertValue(parameters[index].ParameterType, suppliedArguments[index], out var convertedArgument))
{
invocationArguments = Array.Empty<object?>();
return false;
}
invocationArguments[index] = convertedArgument;
}
for (var index = suppliedArguments.Length; index < parameters.Length; index++)
{
var parameter = parameters[index];
if (parameter.ParameterType == typeof(CancellationToken))
{
invocationArguments[index] = cancellationToken;
continue;
}
if (parameter.IsOptional)
{
invocationArguments[index] = GetOptionalParameterValue(parameter);
continue;
}
invocationArguments = Array.Empty<object?>();
return false;
}
return true;
}
private static object? GetOptionalParameterValue(ParameterInfo parameter)
{
var defaultValue = parameter.DefaultValue;
if (defaultValue is not null && defaultValue != DBNull.Value && defaultValue != Type.Missing)
{
return defaultValue;
}
return parameter.ParameterType.IsValueType
? Activator.CreateInstance(parameter.ParameterType)
: null;
}
private static void SetPolicyValue(UserPolicy policy, string memberName, object? value)
{
var policyType = policy.GetType();
var property = policyType.GetProperty(memberName, BindingFlags.Instance | BindingFlags.Public);
if (property is not null && property.CanWrite && TryConvertValue(property.PropertyType, value, out var convertedPropertyValue))
{
property.SetValue(policy, convertedPropertyValue);
return;
}
var field = policyType.GetField(memberName, BindingFlags.Instance | BindingFlags.Public);
if (field is not null && TryConvertValue(field.FieldType, value, out var convertedFieldValue))
{
field.SetValue(policy, convertedFieldValue);
}
}
private static Guid GetUserId(object user)
{
var value = GetUserValue(user, "Id");
if (value is Guid guid)
{
return guid;
}
if (value is string text && Guid.TryParse(text, out var parsedGuid))
{
return parsedGuid;
}
return Guid.Empty;
}
private static string GetUserName(object user)
{
var value = GetUserValue(user, "Username");
if (value is string username && !string.IsNullOrWhiteSpace(username))
{
return username;
}
value = GetUserValue(user, "Name");
return value as string ?? string.Empty;
}
private static object? GetUserValue(object user, string propertyName)
{
var property = user.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
return property?.GetValue(user);
}
private static bool TryConvertValue(Type targetType, object? value, out object? converted)
{
if (targetType.IsByRef)
{
targetType = targetType.GetElementType() ?? targetType;
}
var nonNullableType = Nullable.GetUnderlyingType(targetType) ?? targetType;
if (value is null)
{
converted = null;
return !nonNullableType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null;
}
if (nonNullableType.IsInstanceOfType(value) || targetType.IsAssignableFrom(value.GetType()))
{
converted = value;
return true;
}
if (nonNullableType.IsEnum)
{
if (value is string text)
{
converted = Enum.Parse(nonNullableType, text, ignoreCase: true);
return true;
}
if (IsNumeric(value))
{
converted = Enum.ToObject(nonNullableType, value);
return true;
}
}
if (nonNullableType == typeof(Guid) && value is string guidText && Guid.TryParse(guidText, out var guid))
{
converted = guid;
return true;
}
if (value is IConvertible)
{
try
{
converted = Convert.ChangeType(value, nonNullableType, CultureInfo.InvariantCulture);
return true;
}
catch
{
// The caller will ignore the missing or incompatible policy member.
}
}
converted = null;
return false;
}
private static bool IsNumeric(object value)
{
return value is byte
or sbyte
or short
or ushort
or int
or uint
or long
or ulong
or float
or double
or decimal;
}
private sealed record InvocationCandidate(string MethodName, object?[] Arguments);
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes) private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{ {
return Convert.ToBase64String(bytes) return Convert.ToBase64String(bytes)
+6
Voir le fichier
@@ -100,6 +100,12 @@ public sealed class ShareLinkCleanupService : IShareLinkCleanupService
return record; return record;
} }
// The share URL carries the raw token, and it is kept on the record only so
// the dashboard can offer "copy" while the link is still usable. Once the
// link is torn down the token is dead weight, so drop it rather than leave
// it sitting in the store for good.
record.ShareUrl = null;
var errors = new List<string>(); var errors = new List<string>();
try try
{ {
+25 -5
Voir le fichier
@@ -24,6 +24,7 @@ public sealed class ShareLinkRedemptionService
private readonly ShareLinkCleanupService _cleanupService; private readonly ShareLinkCleanupService _cleanupService;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly ILogger<ShareLinkRedemptionService> _logger; private readonly ILogger<ShareLinkRedemptionService> _logger;
private readonly SemaphoreSlim _redeemGate = new(1, 1);
/// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="ShareLinkRedemptionService"/> class.</summary>
public ShareLinkRedemptionService( public ShareLinkRedemptionService(
@@ -48,6 +49,23 @@ public sealed class ShareLinkRedemptionService
/// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary> /// <summary>Redeems a token and returns the bootstrap HTML, or null if the token is unusable.</summary>
public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken) public async Task<string?> RedeemAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{
// One redemption at a time: the status checks below and the status write
// that follows them are not atomic, so two requests arriving together with
// the same one-use token would otherwise both mint a guest session.
await _redeemGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await RedeemInternalAsync(rawToken, request, cancellationToken).ConfigureAwait(false);
}
finally
{
_redeemGate.Release();
}
}
/// <summary>Runs a single redemption; callers must hold the redemption gate.</summary>
private async Task<string?> RedeemInternalAsync(string rawToken, HttpRequest request, CancellationToken cancellationToken)
{ {
var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false); var tokenHash = await _tokenService.HashTokenAsync(rawToken, cancellationToken).ConfigureAwait(false);
if (tokenHash is null) if (tokenHash is null)
@@ -73,6 +91,13 @@ public sealed class ShareLinkRedemptionService
return null; return null;
} }
// Checked before any library write: re-tagging the whole tree on every hit
// to an already-spent link would be a pointless metadata write storm.
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
{
return null;
}
if (!Guid.TryParse(record.ItemId, out var itemId)) if (!Guid.TryParse(record.ItemId, out var itemId))
{ {
await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false); await HandleFailureAsync(record, "Shared item snapshot is invalid.", cancellationToken).ConfigureAwait(false);
@@ -92,11 +117,6 @@ public sealed class ShareLinkRedemptionService
record.MetadataTouched = true; record.MetadataTouched = true;
} }
if (record.OneUse && record.Status == ShareLinkStatus.Redeemed)
{
return null;
}
if (string.IsNullOrWhiteSpace(record.DeviceId)) if (string.IsNullOrWhiteSpace(record.DeviceId))
{ {
record.DeviceId = Guid.NewGuid().ToString("N"); record.DeviceId = Guid.NewGuid().ToString("N");
+25
Voir le fichier
@@ -111,6 +111,9 @@ public sealed class ShareTokenService
_secretKey = Base64UrlDecode(secretText.Trim()); _secretKey = Base64UrlDecode(secretText.Trim());
if (_secretKey.Length >= 16) if (_secretKey.Length >= 16)
{ {
// Also applied on load so a key written by an older build
// stops being world readable.
RestrictToOwner(_secretPath);
return _secretKey; return _secretKey;
} }
} }
@@ -124,6 +127,7 @@ public sealed class ShareTokenService
RandomNumberGenerator.Fill(generated); RandomNumberGenerator.Fill(generated);
Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!); Directory.CreateDirectory(Path.GetDirectoryName(_secretPath)!);
await File.WriteAllTextAsync(_secretPath, Base64UrlEncode(generated), cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(_secretPath, Base64UrlEncode(generated), cancellationToken).ConfigureAwait(false);
RestrictToOwner(_secretPath);
_secretKey = generated; _secretKey = generated;
return _secretKey; return _secretKey;
} }
@@ -133,6 +137,27 @@ public sealed class ShareTokenService
} }
} }
/// <summary>
/// Keeps the HMAC key readable by the server account only. Best effort: a
/// no-op on platforms without Unix file modes.
/// </summary>
private void RestrictToOwner(string path)
{
if (OperatingSystem.IsWindows())
{
return;
}
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "ShareLinks: could not restrict permissions on the token secret file.");
}
}
private static string ComputeHash(byte[] secret, ReadOnlySpan<byte> tokenBytes) private static string ComputeHash(byte[] secret, ReadOnlySpan<byte> tokenBytes)
{ {
using var hmac = new HMACSHA256(secret); using var hmac = new HMACSHA256(secret);
+4 -3
Voir le fichier
@@ -1111,14 +1111,15 @@
} }
function chooseExpiryHours(config, onChoose) { function chooseExpiryHours(config, onChoose) {
var options = durationOptions.map(function (option) { var maxHours = clampPositiveInteger(config && config.MaxExpiryHours, 720);
var options = durationOptions.filter(function (option) {
return option.hours <= maxHours;
}).map(function (option) {
return { return {
label: durationLabel(option.hours), label: durationLabel(option.hours),
hours: option.hours hours: option.hours
}; };
}); });
var maxHours = Math.max(clampPositiveInteger(config && config.MaxExpiryHours, 720), 720);
var nowMs = Date.now(); var nowMs = Date.now();
var minDate = new Date(nowMs + 5 * 60000); var minDate = new Date(nowMs + 5 * 60000);
var maxDate = new Date(nowMs + maxHours * 3600000); var maxDate = new Date(nowMs + maxHours * 3600000);
+11 -5
Voir le fichier
@@ -35,8 +35,9 @@ real user or handing over a login that sees everything.
and records the share. Share a series or a season and the tag is applied to and records the share. Share a series or a season and the tag is applied to
the whole tree underneath it too - series, seasons and episodes - so the the whole tree underneath it too - series, seasons and episodes - so the
guest can actually browse from the series page down into a season and an guest can actually browse from the series page down into a season and an
episode, not just see a single locked node. The raw link token is shown to episode, not just see a single locked node. Lookups only ever go through a
you once and never stored, only a keyed HMAC hash of it is kept. keyed HMAC hash of the token, and the link itself is dropped from the record
once it is revoked or expired.
3. Whoever opens the link gets a throwaway guest user created on the spot, 3. Whoever opens the link gets a throwaway guest user created on the spot,
restricted by that tag to the shared item and its tree, and is signed in restricted by that tag to the shared item and its tree, and is signed in
automatically. They land on the title's page. automatically. They land on the title's page.
@@ -94,8 +95,10 @@ only a keyed HMAC hash of the token plus the metadata needed to audit and clean
up the link. So: up the link. So:
1. raw tokens are never logged 1. raw tokens are never logged
2. raw tokens are never written to disk 2. only the token's HMAC hash is used to look a link up
3. the token is only returned in the creation response 3. the finished share URL is kept on the record while the link is live, so the
dashboard can re-copy it, and is dropped again the moment the link is revoked
or expires
4. token validation is a hash comparison 4. token validation is a hash comparison
5. guest-user creation and teardown live behind explicit service calls 5. guest-user creation and teardown live behind explicit service calls
6. the real access boundary is the server-side tag policy; the web-client 6. the real access boundary is the server-side tag policy; the web-client
@@ -106,7 +109,10 @@ on the server, using Jellyfin's own session manager. No password is ever stored
anywhere, not even encrypted, and no password ever appears in the page sent to anywhere, not even encrypted, and no password ever appears in the page sent to
the guest. The only thing the guest's browser receives is a session token the guest. The only thing the guest's browser receives is a session token
scoped to that one guest account, and that token dies the moment the guest scoped to that one guest account, and that token dies the moment the guest
account is cleaned up. account is cleaned up. On top of that, the guest account is assigned an authentication provider that
refuses every interactive sign-in, so the normal login page cannot be used to get
into a guest account at all, password or not. If the plugin is disabled Jellyfin
falls back to its own invalid-provider handling, which refuses too.
## Configuration ## Configuration