fix(552): fold #552 security-review findings

Cold review (no Critical/High). Folded:
- Low: clamp JWT:BrowserTokenLifetimeMinutes to a 24h max so a seconds-vs-minutes
  typo can't mint a multi-year bearer token (non-positive/unparseable still falls
  back to 60 min).
- Low: reset the SPA iptv-token cache on the preview panel's Retry and on each
  troubleshooting Play, so a stale token (key rotated) or a stale "JWT disabled"
  latch (backend reconfigured since page load) can't wedge a user-initiated retry.

Deferred to #559 (tracked): redact access_token from Serilog request logs and set
no-store on token-bearing /iptv manifests — pre-existing properties of the shared
?access_token= transport (Jellyfin/M3U already use it), now bounded by the 60-min
lifetime; cross-cutting fixes beyond this feature's scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 17:34:01 +02:00
co-authored by Claude Opus 4.8
parent f8ae4d62ab
commit 60a0c50578
6 changed files with 48 additions and 11 deletions
+11
View File
@@ -54,6 +54,17 @@ public class JwtHelperTests
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
}
[TestCase("1441")]
[TestCase("600000")]
[TestCase("2147483647")]
public void Browser_Token_Lifetime_Is_Clamped_To_The_Maximum(string value)
{
// A value above the documented max (24h) — including a seconds-vs-minutes typo — is clamped, never
// honored literally into a multi-year bearer token.
JwtHelper.Init(Config(value));
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(1440));
}
[Test]
public void GenerateBrowserToken_Expiry_Reflects_Configured_Lifetime()
{
+8 -2
View File
@@ -11,6 +11,11 @@ public static class JwtHelper
// Retry (which mints a fresh token). Operators can override via JWT:BrowserTokenLifetimeMinutes (#552).
private static readonly TimeSpan DefaultBrowserTokenLifetime = TimeSpan.FromMinutes(60);
// Upper bound on the configurable lifetime (24h). A global bearer token that lives longer magnifies the
// exposure of any log/cache leak, and a typo (e.g. a value meant as seconds) shouldn't mint a
// multi-year token; a configured value above this is clamped down rather than honored literally.
private const int MaxBrowserTokenLifetimeMinutes = 1440;
public static SymmetricSecurityKey IssuerSigningKey { get; private set; }
public static bool IsEnabled { get; private set; }
@@ -26,10 +31,11 @@ public static class JwtHelper
}
// A non-positive or unparseable value falls back to the default rather than minting a token that is
// already expired (which would break preview/troubleshooting entirely under JWT).
// already expired (which would break preview/troubleshooting entirely under JWT); a value above the
// documented maximum is clamped down (see MaxBrowserTokenLifetimeMinutes).
if (int.TryParse(configuration["JWT:BrowserTokenLifetimeMinutes"], out int minutes) && minutes > 0)
{
BrowserTokenLifetime = TimeSpan.FromMinutes(minutes);
BrowserTokenLifetime = TimeSpan.FromMinutes(Math.Min(minutes, MaxBrowserTokenLifetimeMinutes));
}
else
{
+16 -6
View File
@@ -454,12 +454,22 @@ closes both with one seam.
and the token is minted only to the already-authenticated admin who can reach every channel.
Channel-scoping would mean adding claim-based auth to `ConditionalIptvAuthorizeFilter` and the streaming
path — deferred until a non-admin preview audience exists.
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override.** The token re-validates on every
`/iptv/*` request, so lifetime is the max continuous watch before playback stalls; 60 min comfortably
covers an operator verification session, an expired idle session just needs Retry (mints fresh), and a
security-conscious operator can tighten it. A non-positive/unparseable value falls back to the default
rather than minting an already-expired token. **Revocation is by short lifetime only** — a stateless JWT
has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all tokens (the existing lever).
- **Lifetime: 60 min default, `JWT:BrowserTokenLifetimeMinutes` override, clamped to 24h.** The token
re-validates on every `/iptv/*` request, so lifetime is the max continuous watch before playback stalls;
60 min comfortably covers an operator verification session, an expired idle session just needs Retry
(mints fresh), and a security-conscious operator can tighten it. A non-positive/unparseable value falls
back to the default rather than minting an already-expired token; a value above 24h (a seconds-vs-minutes
typo would otherwise mint a multi-year bearer token) is clamped down. **Revocation is by short lifetime
only** — a stateless JWT has no per-token revocation; rotating `JWT:IssuerSigningKey` invalidates all
tokens (the existing lever). The SPA's `resetIptvTokenCache()` (called on the preview panel's Retry and on
each troubleshooting Play) makes a user-initiated retry re-mint, so a stale token or a stale "JWT disabled"
latch from a since-reconfigured backend can't wedge a recovery attempt.
- **Deferred hardening (broader than #552).** The `?access_token=` transport itself has two pre-existing
weaknesses this feature inherits, now bounded by the short lifetime: Serilog's request log includes the
full query (so a token can reach logs on an `/iptv` 5xx), and the token-bearing dynamic manifests carry no
`Cache-Control: no-store`. Both predate this feature (Jellyfin and the M3U playlist already pass
`access_token` in `/iptv` URLs) and their fixes are cross-cutting changes to shared request-logging /
manifest behavior — tracked as a follow-up in #559, not folded here.
- **Only the top-level manifest needs the token.** The multi-variant playlist embeds `access_token` into
its variant URL (`IptvController.GetMultiVariantPlaylist`), and HLS segments are served by
`UseStaticFiles` at `RequestPath=/iptv/session`**outside** `ConditionalIptvAuthorizeFilter` (which is
@@ -24,7 +24,7 @@ import {
type Watermark
} from '../api';
import { HlsPlayer } from '../media/HlsPlayer';
import { withIptvToken } from '../media/iptvToken';
import { resetIptvTokenCache, withIptvToken } from '../media/iptvToken';
import { parseDurationSeconds } from '../media/mediaKinds';
// StreamingMode.HttpLiveStreamingSegmenter — mirrors PlaybackTroubleshooting.razor, which always
@@ -449,6 +449,9 @@ export function PlaybackTroubleshootingScreen() {
return;
}
setNotice(null);
// Each explicit Play re-evaluates the IPTV token from scratch (drops any stale token / "JWT disabled"
// latch cached from a prior config), so a manual retry can't be wedged by a stale credential (#552).
resetIptvTokenCache();
setPlaybackStatus((current) => (current ? { ...current, logs: null, speed: null } : current));
setPhase('starting');
startingSinceRef.current = Date.now();
@@ -2,7 +2,7 @@ import { act } from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChannelPreviewAvailability } from '../../api/channels';
import { withIptvToken } from '../../media/iptvToken';
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
import { ChannelPreviewPanel } from './ChannelPreviewPanel';
// The panel resolves the tokened src via withIptvToken (#552) before mounting the player. Mock it to
@@ -15,6 +15,7 @@ vi.mock('../../media/iptvToken', () => ({
}));
const withIptvTokenMock = vi.mocked(withIptvToken);
const resetIptvTokenCacheMock = vi.mocked(resetIptvTokenCache);
const hlsMock = {
isSupported: true,
@@ -230,6 +231,8 @@ describe('ChannelPreviewPanel', () => {
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8');
// Retry drops the token cache so a stale/expired token can't wedge the recovery attempt.
expect(resetIptvTokenCacheMock).toHaveBeenCalled();
});
it('clicking Retry clears a prior error', async () => {
@@ -3,7 +3,7 @@ import type { ChannelPreviewAvailability } from '../../api/channels';
import { Button } from '../../components/forms';
import { SlideOver } from '../../components/overlay';
import { HlsPlayer } from '../../media/HlsPlayer';
import { withIptvToken } from '../../media/iptvToken';
import { resetIptvTokenCache, withIptvToken } from '../../media/iptvToken';
export const FORCED_HLS_CAVEAT =
"This channel is configured for Transport Stream, which browsers cannot play. This preview forces an HLS segmenter session — it checks the content, not the channel's configured pipeline.";
@@ -114,6 +114,10 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
// retries hides the fault it exists to reveal). Retry re-issues the manifest request by bumping
// playToken and clears the previous error so a stale failure doesn't linger over a fresh attempt.
const onRetry = useCallback(() => {
// Retry is the recovery affordance, so re-evaluate the token from scratch: drop the cache so a stale
// token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge
// playback. An actually-expired token would refresh on its own, but this also covers those edges (#552).
resetIptvTokenCache();
failedRef.current = false;
setError(null);
setState('starting');