github ZL154/JellyfinSecurity v2.4.12

latest releases: v2.5.18, v2.5.17, v2.5.16...
one month ago

v2.4.12 — Bug fix: SessionStarted LAN-bypass hole closed + 2FA-login 401 burst eliminated for reverse-proxy fail2ban

Three fixes bundled together. Verified live against a real Cloudflare → nginx → Jellyfin deployment before tagging (login flows: passkey, normal, OIDC, email, non-2FA; clients: Jellyfin Web, Streamyfin, Swiftfin; LAN bypass + external XFF resolution + video playback all confirmed green).

What broke (A — LAN bypass via reverse proxy on follow-up session events)

Issue #35 follow-up reported by @FsxShader2012. v2.4.11 closed the initial-login path by reading X-Forwarded-For synchronously in AuthenticationEventHandler.OnSessionStarted. But SessionStarted also fires from websocket reconnects, internal Jellyfin jobs, and other non-HTTP-request contexts where there's no live HttpContext.Request to read XFF from. In those follow-up events the captured forwardedFor was null, the bypass evaluator fell back to the peer IP (the proxy itself), matched the admin's LAN-bypass CIDR, and silently approved an unverified token via ApproveToken. An unverified user could ride a follow-up websocket event into a bypassed session.

What broke (B + C — 401 burst trips reverse-proxy fail2ban)

Issue #36 reported by @Wibbles42. SWAG (linuxserver.io's docker-swag container) ships a default nginx-unauthorized fail2ban jail that matches the literal status 401 in the nginx access log and bans the source IP after 5 occurrences in 10 minutes. Every legitimate 2FA login on this plugin generated ~15 blocked-token 401s — Jellyfin Web fires a barrage of post-login API calls (/Sessions/Capabilities/Full, /DisplayPreferences/usersettings, /socket, /System/Endpoint, …) each carrying the access token before the user can complete 2FA, and RequestBlockerMiddleware 401s every one. So a single clean login tripped the jail and banned whatever IP nginx saw (Cloudflare's edge IP, the Docker bridge gateway, or the user's real IP), breaking every service behind the proxy until the ban expired. Wibbles42 confirmed the temporary workaround (raising maxretry globally) restored access but rightly didn't want to weaken his brute-force protection across all his services to accommodate one plugin.

Fix A — refuse LAN bypass when XFF is missing and peer is a trusted proxy

  • New helper BypassEvaluator.ShouldRefuseLanBypassWhenXffMissing(forwardedFor, remoteIp, trustedProxyCidrs) — returns true only when TrustForwardedFor is on, forwardedFor is null/empty, and the peer falls inside TrustedProxyCidrs. Refusing LAN bypass in that state is the safe default: we know the request came through the proxy chain but can't recover the real upstream client IP, so treating the proxy itself as a LAN client would defeat the gate.
  • Direct LAN clients (peer NOT in TrustedProxyCidrs) keep working — they fall through to the standard LAN-CIDR check with refuseLanBypass=false. The everyday LAN-bypass case is unaffected.
  • 9 new regression tests cover the trigger case, present-XFF case, direct-LAN case, empty-trusted-proxies case, empty-remoteIp variants, multi-CIDR proxy list, and whitespace-only XFF.

Fix B — return 403 (was 401) for blocked-token-2FA-pending in RequestBlockerMiddleware

  • 403 is more semantically correct (we DO know who the caller is, they just lack permission until 2FA completes), and crucially nginx-unauthorized's failregex only matches 401 — 403s are not counted. One legitimate 2FA login no longer trips the jail.
  • Response body augmented with "twoFactorRequired":true so the client-side short-circuit (Fix C) can detect it from any endpoint, not just the auth-path response.

Fix C — inject.js client-side short-circuit

  • On the first 401 or 403 response carrying twoFactorRequired:true, set a sessionStorage.__tfa_pending flag (5-min TTL, self-clearing on stale reads).
  • The window.fetch shim now short-circuits non-allowlisted calls while the flag is set, returning a synthetic 403 immediately without hitting the network. Reduces ~15 actual network requests per 2FA login to ~1-2 (whatever races ahead of the first response).
  • Allowlist mirrors RequestBlockerMiddleware.AlwaysAllowedPaths verbatim so the user can still reach /TwoFactorAuth/Login, /Verify, /Authenticate, etc. — no soft-lock risk.
  • Flag clears on a successful /TwoFactorAuth/Verify or /TwoFactorAuth/Authenticate response, so the dashboard loads cleanly after 2FA completes.
  • The XHR shim only recognises the marker (no short-circuit — synthesising XHR completion events is too brittle for native jellyfin-apiclient consumers). The server-side 401→403 covers any XHR call that leaks through.
  • Net effect: even if the client-side short-circuit misses a call, the server returns 403 which fail2ban's status-401-only regex doesn't count. Admins can keep nginx-unauthorized's default maxretry=5 with no constraints on user count.

Implementation

  • BypassEvaluator.cs: +1 internal static helper, refactored LAN-bypass branch to use it.
  • RequestBlockerMiddleware.cs: status 401 → 403, body gains twoFactorRequired:true field, updated docstring + inline rationale.
  • inject.js: ~80 lines of new helpers (TFA-pending state, allowlist match, synthetic response builder, completion-path detector) + ~30 lines added to the fetch/XHR shims to wire them in. Loader version bumped to v1.4.0 (visible in browser devtools console).
  • No schema changes. No new dependencies. No config changes required.

181/181 tests pass. Build clean with TreatWarningsAsErrors=true.

Upgrade

In-place upgrade. No config changes required.

  • Users behind a reverse proxy with LAN bypass enabled: the SessionStarted follow-up hole closes automatically. You'll see fewer bypass=lan lines in the audit log for proxied users (because they now route through 2FA correctly) and the new Refusing LAN bypass: peer ... is a trusted proxy but no X-Forwarded-For was visible debug log will appear if a websocket reconnect triggers the guard.
  • Users behind SWAG / any nginx-unauthorized fail2ban setup: revert any per-jail or global maxretry bump you added as a workaround. The 401 burst is gone at the source and the residual responses are 403s that the jail doesn't count.
  • Stock Jellyfin Web users: nothing visible changes other than a noticeably quicker login flow when 2FA is required (no more ~15 parallel API requests dying in the background while you're entering your TOTP).
  • Native client users (Swiftfin, Findroid, Jellyfin Android, Tizen): clients don't load inject.js so they don't benefit from Fix C, but they DO benefit from Fix B — the server returns 403 to them on blocked-token calls instead of 401, which fail2ban still doesn't count. The initial auth response from /Users/AuthenticateByName remains 401 so the existing 2FA-aware native flows are unchanged.

Verify the release

cosign verify-blob \
  --certificate Jellyfin.Plugin.TwoFactorAuthv2.4.12.0.zip.pem \
  --signature Jellyfin.Plugin.TwoFactorAuthv2.4.12.0.zip.sig \
  --certificate-identity-regexp 'https://github.com/ZL154/JellyfinSecurity/.+' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  Jellyfin.Plugin.TwoFactorAuthv2.4.12.0.zip

SLSA build provenance: Jellyfin.Plugin.TwoFactorAuthv2.4.12.0.zip.intoto.jsonl — verify with gh attestation verify --owner ZL154 Jellyfin.Plugin.TwoFactorAuthv2.4.12.0.zip.

Don't miss a new JellyfinSecurity release

NewReleases is sending notifications on new releases.