fix(playback): apply header override to Stalker radio, redact mock cookie log

Address Codex review feedback on #1335:

- The radio branch of the Stalker live layout renders the dedicated audio
  player, never WebPlayerViewComponent, so the resolved portal headers were
  built but never applied — an auth-gated radio stream still 403'd. The
  override sync is extracted into ElectronStreamHeadersService (single owner
  of the scoped override slot, with clear-only-while-owning semantics so a
  destroyed consumer cannot wipe a newer consumer's override), applied by
  WebPlayerViewComponent for video players and by the radio branch before
  the audio element gets its URL. The service feature-detects the bridge
  method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
  presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
  the Electron e2e covers the radio path end-to-end (bare request 403s,
  built-in audio player advances past the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-02 08:34:21 +02:00
1 parent f196abd963
commit c2d1bff89d
11 files changed
+423 -85

No files matched your search

@@ -91,3 +91,58 @@ test('@electron @stalker built-in player plays an auth-gated portal stream', asy
await closeElectronApp(app);
}
});
test('@electron @stalker built-in audio player plays an auth-gated radio stream', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
// The radio branch renders the dedicated audio player instead of
// WebPlayerViewComponent, so it exercises the Stalker live layout's own
// header wiring — a gap the ITV test above cannot catch.
const bareResponse = await request.get(
`${stalkerMockServer}/stream/gated/audio.mp4`
);
expect(bareResponse.status()).toBe(403);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
macAddress: GATED_MAC,
portalUrl: FULL_PORTAL_URL,
});
await waitForStalkerCatalog(app.mainWindow);
await app.mainWindow.getByRole('link', { name: /radio/i }).click();
await app.mainWindow.waitForURL(/stalker.*radio/);
const categories = app.mainWindow.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.first().click();
const channels = app.mainWindow.locator(
'[data-test-id="channel-item"]'
);
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
await channels.first().click();
// The bare <audio> element renders zero-size (its UI is custom), so
// assert attachment rather than visibility.
const audio = app.mainWindow.locator('app-audio-player audio').first();
await expect(audio).toBeAttached({ timeout: 15_000 });
await expect
.poll(
() =>
audio.evaluate(
(element: HTMLAudioElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0.5);
} finally {
await closeElectronApp(app);
}
});
@@ -28,8 +28,12 @@ export function handleCreateLink(req: Request, res: Response): void {
typeof req.get === 'function'
? buildRequestOrigin(req)
: `http://${req.headers['host'] ?? 'localhost:3210'}`;
// Radio plays in an <audio> element, which needs a fixture with an audio
// track; ITV/VOD get the video fixture.
const gatedFile =
req.query['type'] === 'radio' ? 'audio.mp4' : 'video.mp4';
const streamUrl = getScenario(mac).gatedStream
? `${requestOrigin}/stream/gated/video.mp4`
? `${requestOrigin}/stream/gated/${gatedFile}`
: resolveStreamUrl(cmd, itemIndex);
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
+31 -16
View File
@@ -177,28 +177,43 @@ app.get('/stalker', (req: Request, res: Response) => {
});
/**
* Auth-gated media endpoint for the `gated-stream` scenario. A real portal's
* streamer sits behind the same session gate as the API, so this route
* requires the mac cookie AND the MAC's Bearer token and answers 403
* otherwise. It is the only automated proof that a player's actual media
* Auth-gated media endpoints for the `gated-stream` scenario. A real portal's
* streamer sits behind the same session gate as the API, so these routes
* require the mac cookie AND the MAC's Bearer token and answer 403
* otherwise. They are the only automated proof that a player's actual media
* requests carry the portal credentials — a unit test cannot show that a
* header reached the video element.
* header reached the video (or audio) element.
*
* The body is the shared clear (non-DRM) fragmented-MP4 fixture from the
* DASH e2e suite; `sendFile` supplies Range support for progressive playback.
* The bodies are the shared clear (non-DRM) fragmented-MP4 fixtures from the
* DASH e2e suite (video for ITV, audio-only for radio); `sendFile` supplies
* Range support for progressive playback.
*/
const GATED_STREAM_FIXTURE = join(
process.cwd(),
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
);
const GATED_STREAM_FIXTURES: Record<string, string> = {
'audio.mp4': join(
process.cwd(),
'apps/web-e2e/src/fixtures/dash/clear-audio.mp4'
),
'video.mp4': join(
process.cwd(),
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
),
};
app.get('/stream/gated/:file', (req: Request, res: Response) => {
const fixture = GATED_STREAM_FIXTURES[req.params['file'] ?? ''];
if (!fixture) {
res.status(404).type('text/plain').send('Not found');
return;
}
app.get('/stream/gated/video.mp4', (req: Request, res: Response) => {
const failure = checkRequestAuthorization(req, true);
if (failure) {
// Log only header PRESENCE: the cookie carries the mac session
// credential and must never reach terminal/CI logs verbatim.
console.log(
`[gated-stream] 403 (${failure}) cookie=${String(
req.headers['cookie'] ?? '<none>'
)} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
`[gated-stream] 403 (${failure}) cookie=${
req.headers['cookie'] ? 'present' : '<none>'
} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
);
res.status(403).type('text/plain').send(failure);
return;
@@ -207,7 +222,7 @@ app.get('/stream/gated/video.mp4', (req: Request, res: Response) => {
// `dotfiles: 'allow'`: express refuses any path with a dot-segment by
// default, and git worktrees live under `.claude/worktrees/…` — without
// this the fixture 404s in every worktree checkout.
res.sendFile(GATED_STREAM_FIXTURE, {
res.sendFile(fixture, {
dotfiles: 'allow',
headers: { 'Content-Type': 'video/mp4' },
});
+10 -5
View File
@@ -132,11 +132,16 @@ The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders
listener and updates layered in-memory overrides instead of stacking a new
listener for every channel change.
`WebPlayerViewComponent` is the single renderer owner of the scoped override:
it extracts the full header set from the resolved playback (including the
Stalker mac cookie and Bearer token), configures the override **before**
handing the source to any built-in player, and clears the scoped layer on
destroy. Individual player components must not call the bridge themselves — a
`ElectronStreamHeadersService` (`libs/ui/playback`) is the single renderer
owner of the scoped override slot: it extracts the full header set from the
resolved playback (including the Stalker mac cookie and Bearer token), and
its `clear()` releases the slot only while the caller's stream still owns it,
so a consumer being destroyed cannot wipe an override a newer consumer just
configured. Two surfaces apply it: `WebPlayerViewComponent` for every
built-in video player (configuring the override **before** handing the
source over, clearing on destroy), and the Stalker live layout for the
dedicated radio audio player, which never mounts a `WebPlayerViewComponent`.
Individual player components must not call the bridge themselves — a
narrower call would overwrite the credentialed override.
Rules:
+6 -4
View File
@@ -190,10 +190,12 @@ stream and attaches the same portal header set through
`buildStalkerExternalPlaybackHeaders()`
(`libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts`).
The resolved `ResolvedPortalPlayback.headers` feed both the external players
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in web players (via
the scoped Electron request-header override owned by `WebPlayerViewComponent`
— see `docs/architecture/electron-security.md`, "Scoped Request Header
Overrides").
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
scoped Electron request-header override (`ElectronStreamHeadersService`,
applied by `WebPlayerViewComponent` for the video players and by the Stalker
live layout for the radio audio player, which renders outside
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
"Scoped Request Header Overrides").
Two stream profiles exist, selected by one shared predicate:
@@ -279,6 +279,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
settingsStore.resolvedEpgViewMode.set('timeline');
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
updateRemoteControlStatus: jest.fn(),
onChannelChange: jest.fn(() => jest.fn()),
onRemoteControlCommand: jest.fn(() => jest.fn()),
@@ -1127,6 +1128,46 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(audioPlayer.channelName()).toBe('Jazz FM');
expect(audioPlayer.dispatchAdjacentChannelAction()).toBe(false);
});
it('configures the scoped Electron header override before radio playback starts', async () => {
// The radio branch renders the dedicated audio player, not
// WebPlayerViewComponent — without this wiring an auth-gated portal
// radio stream 403s because its credentials never reach the request.
stalkerStore.selectedContentType.set('radio');
selectedCategoryId.set('radio-all');
selectedItem.set(null);
selectedItvId.set(undefined);
fixture.detectChanges();
resolveRadioPlayback.mockResolvedValue({
streamUrl: 'http://portal.example/radio_2.mpg',
title: 'Portal FM',
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00:1A:79:00:00:01',
Authorization: 'Bearer TOKEN99',
},
});
await component.playChannel(radioChannels()[0]);
await fixture.whenStable();
fixture.detectChanges();
expect(window.electron?.setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
'http://portal.example/radio_2.mpg',
{
authorization: 'Bearer TOKEN99',
cookie: 'mac=00:1A:79:00:00:01',
}
);
const audioPlayer = fixture.debugElement.query(
By.directive(StubAudioPlayerComponent)
).componentInstance as StubAudioPlayerComponent;
expect(audioPlayer.url()).toBe('http://portal.example/radio_2.mpg');
});
});
function buildProgram(channelId: string, title: string): EpgProgram {
@@ -51,6 +51,7 @@ import {
} from '@iptvnator/ui/epg';
import {
AudioPlayerComponent,
ElectronStreamHeadersService,
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
@@ -116,6 +117,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly streamHeaders = inject(ElectronStreamHeadersService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly liveSidebarStateService = inject(
@@ -360,6 +362,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private unsubscribeRemoteCommand?: () => void;
private epgLoadRequestId = 0;
private playbackRequestId = 0;
/** Stream URL of the radio playback whose header override this layout configured. */
private radioHeaderScopeUrl: string | null = null;
private playbackResolution: {
channelId: string;
promise: Promise<ResolvedPortalPlayback>;
@@ -556,6 +560,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
this.removeScrollListener();
// Radio credentials must not outlive this layout; the service no-ops
// when a newer playback already owns the override slot.
this.streamHeaders.clear(this.radioHeaderScopeUrl);
}
isSelectedChannel(item: StalkerItvChannel): boolean {
@@ -587,6 +594,18 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
if (isRadioMode) {
// The radio branch renders the dedicated audio player, not
// WebPlayerViewComponent, so the scoped Electron header
// override (portal cookie/token for auth-gated streams) must
// be configured here BEFORE the audio element gets the URL.
await this.streamHeaders.apply(playback);
if (
requestId !== this.playbackRequestId ||
this.selectedChannelId() !== channelId
) {
return;
}
this.radioHeaderScopeUrl = playback.streamUrl;
this.activePlayback.set(playback);
return;
}
+1
View File
@@ -14,4 +14,5 @@ export * from './lib/portal-inline-player/up-next-rail.util';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/vod-details/vod-details.component';
export * from './lib/web-player-view/electron-stream-headers.service';
export * from './lib/web-player-view/web-player-view.component';
@@ -0,0 +1,141 @@
import { TestBed } from '@angular/core/testing';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const GATED_PLAYBACK = {
streamUrl: STREAM_URL,
title: 'Gated Channel',
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
Authorization: 'Bearer TOKEN123',
},
};
describe('ElectronStreamHeadersService', () => {
let service: ElectronStreamHeadersService;
let setUserAgent: jest.Mock;
beforeEach(() => {
setUserAgent = jest.fn().mockResolvedValue(true);
(window as unknown as { electron?: unknown }).electron = {
setUserAgent,
};
service = TestBed.inject(ElectronStreamHeadersService);
});
afterEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
});
it('returns null without an Electron bridge (PWA)', () => {
delete (window as unknown as { electron?: unknown }).electron;
expect(service.apply(GATED_PLAYBACK)).toBeNull();
expect(setUserAgent).not.toHaveBeenCalled();
});
it('extracts the full header set and scopes it to the stream URL', async () => {
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
expect(setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
STREAM_URL,
{
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
}
);
});
it('prefers explicit userAgent/referer fields over the headers map', async () => {
await service.apply({
...GATED_PLAYBACK,
userAgent: 'ExplicitAgent/1.0',
referer: 'http://explicit.example',
});
expect(setUserAgent).toHaveBeenCalledWith(
'ExplicitAgent/1.0',
'http://explicit.example',
STREAM_URL,
expect.anything()
);
});
it('omits the credentials object when the playback carries none', async () => {
await service.apply({
streamUrl: 'https://example.com/live/plain.m3u8',
title: 'Plain Channel',
userAgent: 'PlainAgent/1.0',
});
expect(setUserAgent).toHaveBeenCalledWith(
'PlainAgent/1.0',
undefined,
'https://example.com/live/plain.m3u8',
undefined
);
});
it('reports a superseded apply as not current', async () => {
const resolvers: Array<() => void> = [];
setUserAgent.mockImplementation(
() =>
new Promise<boolean>((resolve) =>
resolvers.push(() => resolve(true))
)
);
const first = service.apply(GATED_PLAYBACK);
const second = service.apply({
...GATED_PLAYBACK,
streamUrl: 'http://portal.example:8080/live/ch2.ts',
});
resolvers[0]();
resolvers[1]();
await expect(first).resolves.toBe(false);
await expect(second).resolves.toBe(true);
});
it('clears the override only while the caller still owns the slot', async () => {
await service.apply(GATED_PLAYBACK);
setUserAgent.mockClear();
// A stale consumer (e.g. a destroyed component) must not wipe the
// override a newer playback configured.
service.clear('http://portal.example:8080/other-stream.ts');
expect(setUserAgent).not.toHaveBeenCalled();
service.clear(STREAM_URL);
expect(setUserAgent).toHaveBeenCalledWith(
undefined,
undefined,
STREAM_URL
);
});
it('does not clear twice for the same stream', async () => {
await service.apply(GATED_PLAYBACK);
service.clear(STREAM_URL);
setUserAgent.mockClear();
service.clear(STREAM_URL);
expect(setUserAgent).not.toHaveBeenCalled();
});
it('resolves instead of throwing when the bridge rejects', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
/* silence */
});
setUserAgent.mockRejectedValue(new Error('ipc down'));
await expect(service.apply(GATED_PLAYBACK)).resolves.toBe(true);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
function getHeaderValue(
headers: ResolvedPortalPlayback['headers'] | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
const matchingKey = Object.keys(headers).find(
(key) => key.toLowerCase() === name.toLowerCase()
);
return matchingKey ? headers[matchingKey] : undefined;
}
/**
* Single owner of the scoped Electron request-header override for built-in
* playback. There is exactly one scoped override slot in the main process, so
* every surface that plays a stream inline goes through this service:
* `WebPlayerViewComponent` for the web video players, and the Stalker live
* layout for the dedicated radio audio player, which never mounts a
* `WebPlayerViewComponent` at all.
*
* `apply()` extracts the full header set from the resolved playback —
* including the portal Cookie/Authorization that auth-gated streams require —
* and hands it to the main process scoped to the stream's URL. A playback
* without custom headers deliberately clears the previous scoped override so
* stale headers never leak onto the next stream. `clear()` releases the slot
* only while the caller's stream still owns it, so a consumer being destroyed
* cannot wipe the override a newer consumer just configured.
*/
@Injectable({ providedIn: 'root' })
export class ElectronStreamHeadersService {
/** Guards against a superseded apply resolving its header IPC late. */
private syncSequence = 0;
/** Stream URL the currently configured override belongs to. */
private currentScopeUrl: string | null = null;
/**
* Configures the scoped override for this playback. Resolves `true` when
* this apply is still the newest one at the time the main process
* acknowledged it — callers must only hand the source to a player on
* `true`, so the first media request already carries the headers. Returns
* null when there is no Electron bridge (PWA), where sources apply
* synchronously and no override exists.
*/
apply(playback: ResolvedPortalPlayback): Promise<boolean> | null {
// Feature-detect the bridge method rather than the bridge object:
// partial bridges (tests, older preloads) must behave like the PWA
// instead of throwing inside a playback flow.
if (typeof window.electron?.setUserAgent !== 'function') {
return null;
}
const sequence = ++this.syncSequence;
const userAgent =
playback.userAgent ??
getHeaderValue(playback.headers, 'User-Agent');
const referer =
playback.referer ?? getHeaderValue(playback.headers, 'Referer');
const cookie = getHeaderValue(playback.headers, 'Cookie');
const authorization = getHeaderValue(playback.headers, 'Authorization');
this.currentScopeUrl = playback.streamUrl;
return window.electron
.setUserAgent(
userAgent,
referer,
playback.streamUrl,
cookie || authorization ? { authorization, cookie } : undefined
)
.catch((error: unknown) => {
console.warn(
'[ElectronStreamHeaders] Failed to configure request headers:',
error
);
return true;
})
.then(() => sequence === this.syncSequence);
}
/**
* Clears the scoped override — portal credentials must not outlive the
* playback session that needed them. No-ops when another stream has taken
* ownership of the slot since, and always in the PWA.
*/
clear(streamUrl: string | null): void {
if (
typeof window.electron?.setUserAgent !== 'function' ||
streamUrl === null ||
this.currentScopeUrl !== streamUrl
) {
return;
}
this.syncSequence += 1;
this.currentScopeUrl = null;
void window.electron
.setUserAgent(undefined, undefined, streamUrl)
.catch(() => undefined);
}
}
@@ -48,6 +48,7 @@ import {
} from '../playback-diagnostics/playback-diagnostics.util';
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
@@ -224,8 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy {
/** Stream URL the currently configured Electron header override belongs to. */
private headerScopeStreamUrl: string | null = null;
/** Guards against a superseded playback resolving its header IPC late. */
private playbackSyncSequence = 0;
private readonly streamHeaders = inject(ElectronStreamHeadersService);
constructor() {
effect(() => {
@@ -235,20 +235,16 @@ export class WebPlayerViewComponent implements OnDestroy {
const playback = this.resolvedPlayback();
const isLive = this.resolvedIsLive();
this.playbackDiagnostic.set(null);
void this.applyPlayback(playback, isLive);
this.applyPlayback(playback, isLive);
});
}
ngOnDestroy(): void {
// Portal credentials must not outlive the playback session that
// needed them: dropping the scoped override here keeps only the
// playlist-level (unscoped) User-Agent/Referer defaults active.
this.playbackSyncSequence += 1;
if (window.electron && this.headerScopeStreamUrl !== null) {
void window.electron
.setUserAgent(undefined, undefined, this.headerScopeStreamUrl)
.catch(() => undefined);
}
// playlist-level (unscoped) User-Agent/Referer defaults active. The
// service no-ops if a newer consumer already owns the override slot.
this.streamHeaders.clear(this.headerScopeStreamUrl);
}
/**
@@ -263,8 +259,8 @@ export class WebPlayerViewComponent implements OnDestroy {
playback: ResolvedPortalPlayback,
isLive: boolean
): void {
const sequence = ++this.playbackSyncSequence;
const headerSync = this.syncElectronStreamHeaders(playback);
const headerSync = this.streamHeaders.apply(playback);
this.headerScopeStreamUrl = playback.streamUrl;
const handOff = (): void => {
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, isLive);
@@ -275,58 +271,13 @@ export class WebPlayerViewComponent implements OnDestroy {
return;
}
void headerSync.then(() => {
if (sequence === this.playbackSyncSequence) {
void headerSync.then((stillCurrent) => {
if (stillCurrent) {
handOff();
}
});
}
/**
* Single owner of the scoped header override for every built-in player.
* Extracts the full header set from the resolved playback — including the
* portal Cookie/Authorization that auth-gated Stalker streams require —
* and hands it to the main process, scoped to this stream's URL. A
* playback without custom headers deliberately clears the previous scoped
* override so stale headers never leak onto the next stream. Returns null
* when there is no Electron bridge (PWA).
*/
private syncElectronStreamHeaders(
playback: ResolvedPortalPlayback
): Promise<void> | null {
if (!window.electron) {
return null;
}
const userAgent =
playback.userAgent ??
this.getHeaderValue(playback.headers, 'User-Agent');
const referer =
playback.referer ??
this.getHeaderValue(playback.headers, 'Referer');
const cookie = this.getHeaderValue(playback.headers, 'Cookie');
const authorization = this.getHeaderValue(
playback.headers,
'Authorization'
);
this.headerScopeStreamUrl = playback.streamUrl;
return window.electron
.setUserAgent(
userAgent,
referer,
playback.streamUrl,
cookie || authorization ? { authorization, cookie } : undefined
)
.then(() => undefined)
.catch((error: unknown) => {
console.warn(
'[WebPlayerView] Failed to configure Electron request headers:',
error
);
});
}
setVjsOptions(streamUrl: string, isLive = true) {
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
const mimeType =