v2.4.12
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 whenTrustForwardedForis on,forwardedForis null/empty, and the peer falls insideTrustedProxyCidrs. 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 withrefuseLanBypass=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 matches401— 403s are not counted. One legitimate 2FA login no longer trips the jail. - Response body augmented with
"twoFactorRequired":trueso 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 asessionStorage.__tfa_pendingflag (5-min TTL, self-clearing on stale reads). - The
window.fetchshim 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.AlwaysAllowedPathsverbatim so the user can still reach/TwoFactorAuth/Login,/Verify,/Authenticate, etc. — no soft-lock risk. - Flag clears on a successful
/TwoFactorAuth/Verifyor/TwoFactorAuth/Authenticateresponse, 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 defaultmaxretry=5with 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 gainstwoFactorRequired:truefield, 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=lanlines in the audit log for proxied users (because they now route through 2FA correctly) and the newRefusing LAN bypass: peer ... is a trusted proxy but no X-Forwarded-For was visibledebug log will appear if a websocket reconnect triggers the guard. - Users behind SWAG / any nginx-unauthorized fail2ban setup: revert any per-jail or global
maxretrybump 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/AuthenticateByNameremains 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.