Block plugin routes for share guests (#16)

* block plugin routes for share guests

Adds a global MVC filter that refuses share-guest accounts on any plugin
controller. Jellyfin's own API stays open, the share tag policy already
bounds it and playback needs it.

Guests are identified by the auth provider marker on the account, so this
covers a leaked token used from curl or a native client, not just the web
client where the CSS lockdown runs.

* add plugin exception list and honest wording

Config page lists installed plugins with a checkbox each, for the ones that
need to answer guests during playback. Default is unticked.

Renames the hidden selectors setting to say it is cosmetic, and stops the
readme implying the web client lockdown confines anything.

* bump to 1.0.4.0
Cette révision appartient à :
Francois CB
2026-07-27 20:39:21 +02:00
révisé par GitHub
Parent 4c48861506
révision d1ee74677b
8 fichiers modifiés avec 373 ajouts et 23 suppressions
+49
Voir le fichier
@@ -11,6 +11,7 @@ using Jellyfin.Plugin.ShareLinks.Configuration;
using Jellyfin.Plugin.ShareLinks.Models; using Jellyfin.Plugin.ShareLinks.Models;
using Jellyfin.Plugin.ShareLinks.Services; using Jellyfin.Plugin.ShareLinks.Services;
using Jellyfin.Plugin.ShareLinks.Storage; using Jellyfin.Plugin.ShareLinks.Storage;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
@@ -99,6 +100,19 @@ public sealed class ShareLinkGuestStateDto
public string? HiddenSelectors { get; set; } public string? HiddenSelectors { get; set; }
} }
/// <summary>An installed plugin, as offered in the guard's exception list.</summary>
public sealed class ShareLinkPluginDto
{
/// <summary>Gets or sets the plugin id.</summary>
public Guid Id { get; set; }
/// <summary>Gets or sets the plugin's display name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether guests may currently reach it.</summary>
public bool AllowedForGuests { get; set; }
}
/// <summary>ShareLinks API surface.</summary> /// <summary>ShareLinks API surface.</summary>
[ApiController] [ApiController]
[Route("ShareLinks")] [Route("ShareLinks")]
@@ -109,6 +123,7 @@ public sealed class ShareLinksController : ControllerBase
private readonly ShareLinkCleanupService _cleanupService; private readonly ShareLinkCleanupService _cleanupService;
private readonly ShareLinkRedemptionService _redemptionService; private readonly ShareLinkRedemptionService _redemptionService;
private readonly ShareLinkStore _store; private readonly ShareLinkStore _store;
private readonly IPluginManager _pluginManager;
private readonly ILogger<ShareLinksController> _logger; private readonly ILogger<ShareLinksController> _logger;
/// <summary>Initializes a new instance of the <see cref="ShareLinksController"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="ShareLinksController"/> class.</summary>
@@ -118,6 +133,7 @@ public sealed class ShareLinksController : ControllerBase
ShareLinkCleanupService cleanupService, ShareLinkCleanupService cleanupService,
ShareLinkRedemptionService redemptionService, ShareLinkRedemptionService redemptionService,
ShareLinkStore store, ShareLinkStore store,
IPluginManager pluginManager,
ILogger<ShareLinksController> logger) ILogger<ShareLinksController> logger)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@@ -125,6 +141,7 @@ public sealed class ShareLinksController : ControllerBase
_cleanupService = cleanupService; _cleanupService = cleanupService;
_redemptionService = redemptionService; _redemptionService = redemptionService;
_store = store; _store = store;
_pluginManager = pluginManager;
_logger = logger; _logger = logger;
} }
@@ -284,6 +301,38 @@ public sealed class ShareLinksController : ControllerBase
return Ok(new { removed }); return Ok(new { removed });
} }
/// <summary>
/// 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.
/// </summary>
[HttpGet("Admin/Plugins")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")]
public ActionResult<IEnumerable<ShareLinkPluginDto>> Plugins()
{
SetNoStoreHeaders();
if (!User.IsInRole("Administrator"))
{
return Forbid();
}
var allowed = Config.GuestAllowedPluginIds ?? Array.Empty<string>();
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);
}
/// <summary>Returns the guest session state for the current authenticated user.</summary> /// <summary>Returns the guest session state for the current authenticated user.</summary>
[HttpGet("GuestState")] [HttpGet("GuestState")]
[Authorize(AuthenticationSchemes = "CustomAuthentication")] [Authorize(AuthenticationSchemes = "CustomAuthentication")]
+21
Voir le fichier
@@ -1,3 +1,4 @@
using System;
using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.ShareLinks.Configuration; 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 /// 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 /// 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. /// 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
/// <see cref="GuestPluginApiGuardEnabled"/>.
/// </summary> /// </summary>
public string GuestHiddenSelectors { get; set; } = string.Empty; public string GuestHiddenSelectors { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public bool GuestPluginApiGuardEnabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public string[] GuestAllowedPluginIds { get; set; } = Array.Empty<string>();
} }
+3 -3
Voir le fichier
@@ -6,9 +6,9 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace> <RootNamespace>Jellyfin.Plugin.ShareLinks</RootNamespace>
<AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName> <AssemblyName>Jellyfin.Plugin.ShareLinks</AssemblyName>
<Version>1.0.3.0</Version> <Version>1.0.4.0</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion> <AssemblyVersion>1.0.4.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion> <FileVersion>1.0.4.0</FileVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo> <GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors> <TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<ImplicitUsings>disable</ImplicitUsings> <ImplicitUsings>disable</ImplicitUsings>
+8
Voir le fichier
@@ -1,10 +1,12 @@
using Jellyfin.Plugin.ShareLinks.Lifecycle; using Jellyfin.Plugin.ShareLinks.Lifecycle;
using Jellyfin.Plugin.ShareLinks.Security;
using Jellyfin.Plugin.ShareLinks.Services; 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.Authentication;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.ShareLinks; namespace Jellyfin.Plugin.ShareLinks;
@@ -20,6 +22,12 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
{ {
_ = applicationHost; _ = 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<GuestPluginApiGuard>();
serviceCollection.Configure<MvcOptions>(options => options.Filters.AddService<GuestPluginApiGuard>());
serviceCollection.AddHostedService<WebInjectionHostedService>(); serviceCollection.AddHostedService<WebInjectionHostedService>();
serviceCollection.AddSingleton<ShareLinkStore>(); serviceCollection.AddSingleton<ShareLinkStore>();
serviceCollection.AddSingleton<ShareTokenService>(); serviceCollection.AddSingleton<ShareTokenService>();
+178
Voir le fichier
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<GuestPluginApiGuard> _logger;
/// <summary>Initializes a new instance of the <see cref="GuestPluginApiGuard"/> class.</summary>
public GuestPluginApiGuard(
IUserManager userManager,
IPluginManager pluginManager,
ILogger<GuestPluginApiGuard> logger)
{
_userManager = userManager;
_pluginManager = pluginManager;
_logger = logger;
}
/// <inheritdoc />
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);
}
/// <summary>Maps a controller assembly back to the plugin that shipped it.</summary>
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<string>? 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;
}
}
+71 -3
Voir le fichier
@@ -130,8 +130,8 @@
</div> </div>
<div class="sl-field inputContainer" style="grid-column: 1 / -1;"> <div class="sl-field inputContainer" style="grid-column: 1 / -1;">
<input is="emby-input" type="text" id="GuestHiddenSelectors" label="Guest hidden selectors (CSS, comma-separated)" /> <input is="emby-input" type="text" id="GuestHiddenSelectors" label="Cosmetic: hide elements from guests (CSS, comma-separated)" />
<div class="fieldDescription">Elements hidden from guest sessions, e.g. other plugins' buttons. Empty by default.</div> <div class="fieldDescription">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.</div>
</div> </div>
<div class="sl-field inputContainer"> <div class="sl-field inputContainer">
@@ -167,6 +167,21 @@
</div> </div>
</div> </div>
<div class="sl-section">
<h3 class="sectionTitle">Plugin access for guests</h3>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="GuestPluginApiGuardEnabled" />
<span>Block other plugins for guests</span>
</label>
<div class="fieldDescription">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.</div>
</div>
<div class="sl-muted" style="margin-top:0.75rem;">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.</div>
<div id="GuestAllowedPlugins" style="margin-top:0.5rem;">
<div class="sl-muted">Loading plugins…</div>
</div>
</div>
<div class="sl-section"> <div class="sl-section">
<div class="sl-inline"> <div class="sl-inline">
<h3 class="sectionTitle">Share links</h3> <h3 class="sectionTitle">Share links</h3>
@@ -228,11 +243,57 @@
page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false; page.querySelector('#OneUseDefault').checked = cfg.OneUseDefault !== false;
page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers; page.querySelector('#MaxConcurrentViewers').value = cfg.MaxConcurrentViewers === undefined ? 10 : cfg.MaxConcurrentViewers;
page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false; page.querySelector('#GuestModeLockdownEnabled').checked = cfg.GuestModeLockdownEnabled !== false;
page.querySelector('#GuestPluginApiGuardEnabled').checked = cfg.GuestPluginApiGuardEnabled !== false;
}).finally(function () { }).finally(function () {
Dashboard.hideLoadingMsg(); 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 = '<div class="sl-muted">No other plugins installed.</div>';
return;
}
host.innerHTML = items.map(function (plugin) {
return '<div class="checkboxContainer">'
+ '<label>'
+ '<input is="emby-checkbox" type="checkbox" class="sl-plugin-allow" data-plugin-id="'
+ escapeHtml(plugin.Id) + '"' + (plugin.AllowedForGuests ? ' checked' : '') + ' />'
+ '<span>' + escapeHtml(plugin.Name) + '</span>'
+ '</label>'
+ '</div>';
}).join('');
}).catch(function () {
host.innerHTML = '<div class="sl-muted">Could not load the plugin list.</div>';
});
}
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) { function fmtDate(value) {
if (!value) { return 'n/a'; } if (!value) { return 'n/a'; }
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();
@@ -410,6 +471,13 @@
cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked; cfg.OneUseDefault = page.querySelector('#OneUseDefault').checked;
cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0); cfg.MaxConcurrentViewers = Math.max(parseInt(page.querySelector('#MaxConcurrentViewers').value, 10) || 0, 0);
cfg.GuestModeLockdownEnabled = page.querySelector('#GuestModeLockdownEnabled').checked; 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) { ApiClient.updatePluginConfiguration(ShareLinksPluginId, cfg).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
return loadLinks(); return loadLinks();
@@ -422,7 +490,7 @@
document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () { document.querySelector('#ShareLinksConfigPage').addEventListener('pageshow', function () {
page = this; page = this;
loadConfig().then(loadLinks); loadConfig().then(loadPlugins).then(loadLinks);
}); });
document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save); document.querySelector('#ShareLinksConfigForm').addEventListener('submit', save);
+1 -1
Voir le fichier
@@ -1,7 +1,7 @@
{ {
"guid": "68540b76-ee74-436d-85ff-2abc884bbea6", "guid": "68540b76-ee74-436d-85ff-2abc884bbea6",
"name": "ShareLinks", "name": "ShareLinks",
"version": "1.0.3.0", "version": "1.0.4.0",
"targetAbi": "10.11.0.0", "targetAbi": "10.11.0.0",
"framework": "net9.0", "framework": "net9.0",
"owner": "Franciskid", "owner": "Franciskid",
+42 -16
Voir le fichier
@@ -61,13 +61,20 @@ server, not only in the browser:
- The guest's Jellyfin policy only permits items carrying the share's tag, so - 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. 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. 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, - Other plugins' endpoints refuse the guest, on the server, so hiding a plugin's
menu and search buttons are hidden, in-page links (cast, studio, genres) are button is not what keeps a guest out of it. See below.
made inert, any attempt to navigate somewhere outside the shared tree snaps back - On top of that, the web client is tidied for the guest: the home, menu and
to the shared title. Navigating down within what you shared works normally: a search buttons are hidden, in-page links (cast, studio, genres) are made inert,
shared series opens into its seasons and episodes, a shared season into its and navigating outside the shared tree snaps back to the shared title.
episodes. Going up does not, so a guest sent one season cannot reach the series Navigating down within what you shared works normally: a shared series opens
it belongs to. 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 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. 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 of them on the spot. Revoking runs the same teardown as expiry: guest gone and tag
gone. 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 A guest is a real Jellyfin account holding a real access token. That token works
bar, a floating button), you probably do not want a guest to see them. I had anywhere a Jellyfin token works, including curl and the mobile apps, so anything
exactly that problem with a different plugin of mine, so the **Guest hidden that decides who gets in has to decide it on the server.
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 **Block other plugins for guests** does that, and it is on by default. ShareLinks
to hide and add it there 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 ## Security stance
@@ -165,8 +189,10 @@ All of these live on the plugin's dashboard page:
| Cleanup interval | How often the background cleanup runs | | 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) | | 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 | | 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 lockdown | The web-client tidying described above (on by default) |
| Guest hidden selectors | CSS selectors hidden from guests, to suppress other plugins' UI | | 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 ## Known limitation: cast and crew