using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Plugin.ShareLinks.Services;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.ShareLinks.Security;
///
/// Refuses share-guest accounts access to any plugin's API surface.
///
/// The web-client lockdown in sharelinks.js can only hide things from a browser
/// that chooses to run it. A guest holds a real Jellyfin access token, so curl or
/// a native client sees everything the CSS was hiding. This filter is the part
/// that actually holds: it runs server side on every MVC action, so the caller's
/// choice of client is irrelevant.
///
/// The rule is structural rather than a curated route list. Jellyfin's own API
/// lives in one assembly and is already bounded for guests by the share tag
/// policy, so it is allowed wholesale. Everything else is by definition a
/// plugin's controller and is refused unless the admin opted that plugin in.
/// A plugin installed next month is therefore covered on the day it lands.
///
public sealed class GuestPluginApiGuard : IAsyncActionFilter
{
private const string CoreApiAssemblyName = "Jellyfin.Api";
private static readonly Assembly OwnAssembly = typeof(GuestPluginApiGuard).Assembly;
private readonly IUserManager _userManager;
private readonly IPluginManager _pluginManager;
private readonly ILogger _logger;
/// Initializes a new instance of the class.
public GuestPluginApiGuard(
IUserManager userManager,
IPluginManager pluginManager,
ILogger logger)
{
_userManager = userManager;
_pluginManager = pluginManager;
_logger = logger;
}
///
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
if (next is null)
{
throw new ArgumentNullException(nameof(next));
}
if (IsBlocked(context))
{
context.Result = new StatusCodeResult(403);
return;
}
await next().ConfigureAwait(false);
}
/// Maps a controller assembly back to the plugin that shipped it.
public Guid? FindOwningPluginId(Assembly assembly)
{
foreach (var plugin in _pluginManager.Plugins)
{
var instanceAssembly = plugin.Instance?.GetType().Assembly;
if (instanceAssembly is not null && instanceAssembly == assembly)
{
return plugin.Id;
}
}
return null;
}
private bool IsBlocked(ActionExecutingContext context)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.Enabled || !config.GuestPluginApiGuardEnabled)
{
return false;
}
// Only controller actions carry an assembly we can reason about. Anything
// else (Razor pages, raw endpoints) is left alone rather than guessed at.
if (context.ActionDescriptor is not ControllerActionDescriptor descriptor)
{
return false;
}
var assembly = descriptor.ControllerTypeInfo.Assembly;
// Jellyfin's own API: the share tag policy is the boundary here, and it is
// the same boundary playback depends on. Blocking any of it would break the
// guest's ability to watch what they were sent.
if (string.Equals(assembly.GetName().Name, CoreApiAssemblyName, StringComparison.Ordinal))
{
return false;
}
// ShareLinks' own routes must stay reachable: the guest's browser fetches
// the lockdown script and its guest state from here. The admin routes on
// this controller do their own Administrator check and already refuse a guest.
if (assembly == OwnAssembly)
{
return false;
}
var userId = GetUserId(context.HttpContext.User);
if (userId == Guid.Empty)
{
return false;
}
var user = _userManager.GetUserById(userId);
if (user is null)
{
return false;
}
// The marker is written onto the account by JellyfinGuestUserService, and a
// guest cannot clear it: changing a policy needs admin, which they are not.
if (!string.Equals(user.AuthenticationProviderId, GuestAuthenticationProvider.ProviderId, StringComparison.Ordinal))
{
return false;
}
var pluginId = FindOwningPluginId(assembly);
if (pluginId.HasValue && IsAllowedPlugin(config.GuestAllowedPluginIds, pluginId.Value))
{
return false;
}
_logger.LogInformation(
"ShareLinks: refused guest {UserName} access to {Controller}.{Action} ({Assembly}).",
user.Username,
descriptor.ControllerName,
descriptor.ActionName,
assembly.GetName().Name);
return true;
}
private static bool IsAllowedPlugin(IReadOnlyList? allowed, Guid pluginId)
{
if (allowed is null || allowed.Count == 0)
{
return false;
}
return allowed.Any(value => Guid.TryParse(value, out var parsed) && parsed == pluginId);
}
private static Guid GetUserId(ClaimsPrincipal? principal)
{
if (principal is null)
{
return Guid.Empty;
}
var claim = principal.FindFirst("Jellyfin-UserId")?.Value
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(claim, out var id) ? id : Guid.Empty;
}
}