diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
index e63c564..c744f11 100644
--- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
+++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs
@@ -11,6 +11,7 @@ using Jellyfin.Plugin.ShareLinks.Configuration;
using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage;
+using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
@@ -99,6 +100,19 @@ public sealed class ShareLinkGuestStateDto
public string? HiddenSelectors { get; set; }
}
+/// An installed plugin, as offered in the guard's exception list.
+public sealed class ShareLinkPluginDto
+{
+ /// Gets or sets the plugin id.
+ public Guid Id { get; set; }
+
+ /// Gets or sets the plugin's display name.
+ public string Name { get; set; } = string.Empty;
+
+ /// Gets or sets a value indicating whether guests may currently reach it.
+ public bool AllowedForGuests { get; set; }
+}
+
/// ShareLinks API surface.
[ApiController]
[Route("ShareLinks")]
@@ -109,6 +123,7 @@ public sealed class ShareLinksController : ControllerBase
private readonly ShareLinkCleanupService _cleanupService;
private readonly ShareLinkRedemptionService _redemptionService;
private readonly ShareLinkStore _store;
+ private readonly IPluginManager _pluginManager;
private readonly ILogger _logger;
/// Initializes a new instance of the class.
@@ -118,6 +133,7 @@ public sealed class ShareLinksController : ControllerBase
ShareLinkCleanupService cleanupService,
ShareLinkRedemptionService redemptionService,
ShareLinkStore store,
+ IPluginManager pluginManager,
ILogger logger)
{
_libraryManager = libraryManager;
@@ -125,6 +141,7 @@ public sealed class ShareLinksController : ControllerBase
_cleanupService = cleanupService;
_redemptionService = redemptionService;
_store = store;
+ _pluginManager = pluginManager;
_logger = logger;
}
@@ -284,6 +301,38 @@ public sealed class ShareLinksController : ControllerBase
return Ok(new { removed });
}
+ ///
+ /// Lists the installed plugins so the config page can offer them as guard
+ /// exceptions. ShareLinks itself is left out: it is always reachable, since the
+ /// guest's browser fetches the lockdown script and guest state from it.
+ ///
+ [HttpGet("Admin/Plugins")]
+ [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+ public ActionResult> Plugins()
+ {
+ SetNoStoreHeaders();
+ if (!User.IsInRole("Administrator"))
+ {
+ return Forbid();
+ }
+
+ var allowed = Config.GuestAllowedPluginIds ?? Array.Empty();
+ var ownId = Plugin.Instance?.Id;
+
+ var plugins = _pluginManager.Plugins
+ .Where(plugin => !ownId.HasValue || plugin.Id != ownId.Value)
+ .Select(plugin => new ShareLinkPluginDto
+ {
+ Id = plugin.Id,
+ Name = plugin.Name,
+ AllowedForGuests = allowed.Any(value => Guid.TryParse(value, out var parsed) && parsed == plugin.Id)
+ })
+ .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ return Ok(plugins);
+ }
+
/// Returns the guest session state for the current authenticated user.
[HttpGet("GuestState")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
diff --git a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs
index 5a68fcd..5143fe6 100644
--- a/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs
+++ b/Jellyfin.Plugin.ShareLinks/Configuration/PluginConfiguration.cs
@@ -1,3 +1,4 @@
+using System;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.ShareLinks.Configuration;
@@ -52,6 +53,26 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets a comma-separated list of CSS selectors that are hidden from guest
/// sessions in the web client. Used to suppress other plugins' injected UI (search
/// bars, floating buttons) so a guest only sees the shared title. Empty by default.
+ ///
+ /// This is cosmetic only. It runs in the browser, so it tidies the guest's view but
+ /// enforces nothing: the access boundary is the share tag policy plus
+ /// .
///
public string GuestHiddenSelectors { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets a value indicating whether share guests are refused access to other
+ /// plugins' API endpoints. Jellyfin's own API stays reachable, since the share tag
+ /// policy already bounds it and playback depends on it. On by default: a guest holds
+ /// a real access token, so without this any installed plugin answers them directly.
+ ///
+ public bool GuestPluginApiGuardEnabled { get; set; } = true;
+
+ ///
+ /// Gets or sets the plugin ids that guests may reach despite the guard. Empty by
+ /// default, so a newly installed plugin is refused without anyone having to
+ /// remember it. Opt a plugin in when it genuinely needs to serve guests: an
+ /// intro-skip plugin, for instance, is called by the client mid-playback.
+ ///
+ public string[] GuestAllowedPluginIds { get; set; } = Array.Empty();
}
diff --git a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
index 672176a..c9d0aa5 100644
--- a/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
+++ b/Jellyfin.Plugin.ShareLinks/Jellyfin.Plugin.ShareLinks.csproj
@@ -6,9 +6,9 @@
latestJellyfin.Plugin.ShareLinksJellyfin.Plugin.ShareLinks
- 1.0.3.0
- 1.0.3.0
- 1.0.3.0
+ 1.0.4.0
+ 1.0.4.0
+ 1.0.4.0truefalsedisable
diff --git a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs
index 1f72623..5f28bc8 100644
--- a/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs
+++ b/Jellyfin.Plugin.ShareLinks/PluginServiceRegistrator.cs
@@ -1,10 +1,12 @@
using Jellyfin.Plugin.ShareLinks.Lifecycle;
+using Jellyfin.Plugin.ShareLinks.Security;
using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage;
using Jellyfin.Plugin.ShareLinks.Web;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Plugins;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.ShareLinks;
@@ -20,6 +22,12 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
{
_ = applicationHost;
+ // Registered as a global MVC filter so it sees every plugin's controllers,
+ // not just this one's. Options are materialised after all plugin registrators
+ // have run, so adding to the collection here is in time.
+ serviceCollection.AddSingleton();
+ serviceCollection.Configure(options => options.Filters.AddService());
+
serviceCollection.AddHostedService();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
diff --git a/Jellyfin.Plugin.ShareLinks/Security/GuestPluginApiGuard.cs b/Jellyfin.Plugin.ShareLinks/Security/GuestPluginApiGuard.cs
new file mode 100644
index 0000000..ac0c19c
--- /dev/null
+++ b/Jellyfin.Plugin.ShareLinks/Security/GuestPluginApiGuard.cs
@@ -0,0 +1,178 @@
+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;
+ }
+}
diff --git a/Jellyfin.Plugin.ShareLinks/Web/configPage.html b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
index e4a1e62..71377c5 100644
--- a/Jellyfin.Plugin.ShareLinks/Web/configPage.html
+++ b/Jellyfin.Plugin.ShareLinks/Web/configPage.html
@@ -130,8 +130,8 @@
-
-
Elements hidden from guest sessions, e.g. other plugins' buttons. Empty by default.
+
+
Tidies the guest's view only. This runs in the browser and blocks nothing, so do not rely on it to keep a guest out of anything. Use the plugin access section below for that.
@@ -167,6 +167,21 @@
+
+
Plugin access for guests
+
+
+
Refuses guest accounts on other plugins' API endpoints, on the server. A guest holds a real Jellyfin token, so without this any installed plugin answers them directly, whatever the web client shows. Jellyfin's own API stays available: the share tag already limits it to the shared title, and playback needs it.
+
+
Tick a plugin to let guests reach it anyway. Leave everything unticked unless a plugin needs to serve guests during playback, such as an intro skipper.
+
+
Loading plugins…
+
+
+
Share links
@@ -228,11 +243,57 @@
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers;
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
+ page.querySelector('#GuestPluginApiGuardEnabled').checked = cfg.GuestPluginApiGuardEnabled !== false;
}).finally(function () {
Dashboard.hideLoadingMsg();
});
}
+ // The ticked state comes from the server rather than from the config we
+ // just loaded, so a plugin that has since been uninstalled drops out of
+ // the list instead of lingering as a stale checkbox.
+ function loadPlugins() {
+ var host = page.querySelector('#GuestAllowedPlugins');
+ return ApiClient.ajax({
+ type: 'GET',
+ url: ApiClient.getUrl('ShareLinks/Admin/Plugins'),
+ dataType: 'json'
+ }).then(function (list) {
+ var items = Array.isArray(list) ? list : [];
+ if (!items.length) {
+ host.innerHTML = '
';
+ });
+ }
+
+ function collectAllowedPluginIds() {
+ // A failed plugin load leaves no checkboxes to read. Returning the saved
+ // config untouched in that case avoids silently clearing the exceptions.
+ var boxes = page.querySelectorAll('.sl-plugin-allow');
+ if (!boxes.length) {
+ return null;
+ }
+
+ return Array.prototype.filter.call(boxes, function (box) {
+ return box.checked;
+ }).map(function (box) {
+ return box.getAttribute('data-plugin-id');
+ });
+ }
+
function fmtDate(value) {
if (!value) { return 'n/a'; }
return new Date(value).toLocaleString();
@@ -410,6 +471,13 @@
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0);
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked;
+ cfg.GuestPluginApiGuardEnabled = page.querySelector('#GuestPluginApiGuardEnabled').checked;
+
+ var allowedPluginIds = collectAllowedPluginIds();
+ if (allowedPluginIds !== null) {
+ cfg.GuestAllowedPluginIds = allowedPluginIds;
+ }
+
ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
return loadLinks();
@@ -422,7 +490,7 @@
document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () {
page = this;
- loadConfig().then(loadLinks);
+ loadConfig().then(loadPlugins).then(loadLinks);
});
document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save);
diff --git a/Jellyfin.Plugin.ShareLinks/meta.json b/Jellyfin.Plugin.ShareLinks/meta.json
index fbeaa2e..ccecb46 100644
--- a/Jellyfin.Plugin.ShareLinks/meta.json
+++ b/Jellyfin.Plugin.ShareLinks/meta.json
@@ -1,7 +1,7 @@
{
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks",
- "version": "1.0.3.0",
+ "version": "1.0.4.0",
"targetAbi": "10.11.0.0",
"framework": "net9.0",
"owner": "Franciskid",
diff --git a/README.md b/README.md
index bd91fc4..93faaed 100644
--- a/README.md
+++ b/README.md
@@ -61,13 +61,20 @@ server, not only in the browser:
- The guest's Jellyfin policy only permits items carrying the share's tag, so
every other movie, show, library and search comes back empty from the API.
Even someone poking at the raw API cannot list your other content.
-- On top of that, the web client is locked down for the guest: the home,
- menu and search buttons are hidden, in-page links (cast, studio, genres) are
- made inert, any attempt to navigate somewhere outside the shared tree snaps back
- to the shared title. Navigating down within what you shared works normally: a
- shared series opens into its seasons and episodes, a shared season into its
- episodes. Going up does not, so a guest sent one season cannot reach the series
- it belongs to.
+- Other plugins' endpoints refuse the guest, on the server, so hiding a plugin's
+ button is not what keeps a guest out of it. See below.
+- On top of that, the web client is tidied for the guest: the home, menu and
+ search buttons are hidden, in-page links (cast, studio, genres) are made inert,
+ and navigating outside the shared tree snaps back to the shared title.
+ Navigating down within what you shared works normally: a shared series opens
+ into its seasons and episodes, a shared season into its episodes. Going up does
+ not, so a guest sent one season cannot reach the series it belongs to.
+
+ Be clear about what that last part is: it runs in the browser. A guest who
+ disables the script, or who uses their token from another client, can reach the
+ home screen. They find it empty, because the tag policy answers those queries on
+ the server. Which page you are on is the browser's doing, what you can pull is
+ the server's. Only the second one is load bearing.
Playback works normally, including transcoding and remuxing if you allow it, and
the player's back button still returns them to the title's page.
@@ -85,14 +92,31 @@ copyable link, the temporary guest name, and an expiry, and lets you revoke any
of them on the spot. Revoking runs the same teardown as expiry: guest gone and tag
gone.
-## Hiding other plugins from guests
+## Other plugins and guests
-If you run other plugins that inject their own UI into the web client (a search
-bar, a floating button), you probably do not want a guest to see them. I had
-exactly that problem with a different plugin of mine, so the **Guest hidden
-selectors** setting is a comma-separated list of CSS selectors that get hidden
-in guest sessions. You can just add the class name or the id of the element you want
-to hide and add it there
+A guest is a real Jellyfin account holding a real access token. That token works
+anywhere a Jellyfin token works, including curl and the mobile apps, so anything
+that decides who gets in has to decide it on the server.
+
+**Block other plugins for guests** does that, and it is on by default. ShareLinks
+registers a filter that runs on every API request in the server, so it covers
+plugins you did not write and plugins you install later, without those plugins
+needing to know ShareLinks exists. When a guest account calls another plugin's
+endpoint, the request is refused with a 403. Jellyfin's own API is left alone:
+the share tag already limits it to the shared title, and playback runs through it.
+
+Some plugins genuinely need to answer guests. An intro skipper, for instance, is
+called by the client during playback. The config page lists your installed
+plugins with a checkbox each, so you can let those through one at a time.
+Everything starts unticked, so a plugin you install next month is covered on the
+day it lands rather than the day you remember it.
+
+There is also a **cosmetic hidden selectors** box, a comma-separated list of CSS
+selectors hidden in guest sessions. Add the class name or the id of the element
+you want gone and it disappears for guests. It is for tidiness, so a guest is not
+looking at another plugin's floating button. It runs in the browser and enforces
+nothing: anyone who opens devtools or skips the web client sees straight past it.
+Do not use it as a way to keep a guest out of something. The block above is that.
## Security stance
@@ -165,8 +189,10 @@ All of these live on the plugin's dashboard page:
| Cleanup interval | How often the background cleanup runs |
| Maximum viewers per multi-use link | How many people may watch one multi-use link at the same time (default 10, 0 means no limit) |
| Single use by default | How the single-use box starts out in the create popup; it is a per-link choice |
-| Guest lockdown | The web-client confinement described above (on by default) |
-| Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI |
+| Guest lockdown | The web-client tidying described above (on by default) |
+| Block other plugins for guests | Refuses guests on other plugins' API endpoints, server side (on by default) |
+| Plugin access list | Plugins you tick stay reachable by guests despite the block |
+| Cosmetic hidden selectors | CSS selectors hidden from guests. Appearance only, enforces nothing |
## Known limitation: cast and crew