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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user