feat(playback): forward portal Cookie/Authorization to built-in players

The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).

- request-header-overrides.service: the scoped override now carries Cookie
  and Authorization, attached only to requests on the exact stream origin,
  in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
  drop credentials fail-closed; control characters in header values are
  rejected. Chosen over session.cookies.set(): jar cookies attach only to
  credentialed requests, which would force withCredentials into every engine
  and break against the Access-Control-Allow-Origin:* IPTV panels send, and
  jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
  every built-in player: it extracts the full header set from the resolved
  playback, configures the override BEFORE handing the source over (players
  render only once the source exists), and clears the scoped layer on
  destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
  would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
  set ITV already had (they previously carried no portal headers at all);
  same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
  (isStalkerStreamCredentialSafe): same-host port changes and scheme
  upgrades keep the portal profile (the #1158 class), a foreign host or
  https->http downgrade keeps the credential-free KSPlayer profile. The
  main-process fallback context uses the same predicate so
  isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
  ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
  create_link returns a local /stream/gated/video.mp4 that 403s without the
  mac cookie + current Bearer token; new Electron e2e proves a built-in
  player actually plays it (and that the gate refuses bare requests).

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 297e9fbef8
commit f196abd963
27 files changed
+1393 -136

No files matched your search

@@ -0,0 +1,12 @@
---
type: fix
area: playback
issues: [849, 910, 732]
---
Stalker portal streams that require the portal session now play in the
built-in players (HTML5, Video.js, ArtPlayer), not only in VLC/MPV: the
player's own requests carry the portal cookie and token, scoped to that
stream and cleared when playback ends. VOD, series and radio streams get the
same portal headers live TV already had, including on same-host streaming
ports.
@@ -0,0 +1,93 @@
import {
addStalkerPortal,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
stalkerMockServer,
test,
waitForStalkerCatalog,
} from './electron-test-fixtures';
/**
* End-to-end proof that a BUILT-IN player's media requests carry the portal
* credentials (mac cookie + Bearer token) — the root of the long-running
* "only VLC works" cluster (#849, #910, #732): the web players used to
* receive only User-Agent/Referer/Origin, so any stream gated on the portal
* session could never play inline.
*
* The mock's `gated-stream` scenario makes `create_link` return this
* server's own `/stream/gated/video.mp4`, which answers 403 unless the
* request presents the mac cookie AND the MAC's current access token. A unit
* test cannot show that a header reached the video element; playback
* advancing past that gate can only happen when the scoped Electron header
* override attached the credentials to the actual media request.
*/
const GATED_MAC = '00:1A:79:00:00:09';
const GATED_STREAM_URL = `${stalkerMockServer}/stream/gated/video.mp4`;
// The full-portal URL shape: the app handshakes and holds a Bearer token,
// which is exactly what the gated stream endpoint demands.
const FULL_PORTAL_URL = `${stalkerMockServer}/stalker_portal/server/load.php`;
test('@electron @stalker built-in player plays an auth-gated portal stream', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
// First prove the gate is real: a credential-less request is refused, so
// a green playback assertion below cannot be a permissive-mock artifact.
const bareResponse = await request.get(GATED_STREAM_URL);
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);
// The portal lands on Movies; live playback lives in the ITV layout.
await app.mainWindow
.getByRole('link', { name: /live|itv/i })
.click();
await app.mainWindow.waitForURL(/stalker.*itv/);
// The ITV view renders channels only after a category is selected;
// index 0 is the "All channels" pseudo-category.
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();
const video = app.mainWindow
.locator('app-web-player-view video')
.first();
await expect(video).toBeVisible({ timeout: 15_000 });
// Advancing playback past the 403 gate is only possible when the
// media requests carried the portal cookie and Authorization header.
await expect
.poll(
() =>
video.evaluate(
(element: HTMLVideoElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0.5);
await expect(
app.mainWindow.getByTestId('playback-diagnostic-banner')
).toBeHidden();
} finally {
await closeElectronApp(app);
}
});
@@ -140,7 +140,33 @@ describe('main preload DB IPC contract', () => {
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8'
'https://stream.example/live.m3u8',
undefined
);
});
it('forwards scoped stream credentials alongside the header override', async () => {
const api = getExposedApi();
await api.setUserAgent(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8',
{
authorization: 'Bearer TOKEN',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
}
);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
'set-user-agent',
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/live.m3u8',
{
authorization: 'Bearer TOKEN',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01',
}
);
});
@@ -450,8 +450,19 @@ const electronApi: ElectronBridgeApi = {
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl),
scopeUrl?: string | null,
credentials?: {
authorization?: string | null;
cookie?: string | null;
} | null
) =>
ipcRenderer.invoke(
'set-user-agent',
userAgent,
referer,
scopeUrl,
credentials
),
openInMpv: (
url: string,
title: string,
@@ -1,5 +1,8 @@
import { ipcMain } from 'electron';
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
import {
configureRequestHeaderOverride,
type StreamCredentialHeaders,
} from '../services/request-header-overrides.service';
export default class SharedEvents {
static bootstrapSharedEvents(): Electron.IpcMain {
@@ -7,21 +10,46 @@ export default class SharedEvents {
}
}
ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => {
setUserAgent(userAgent, referer, scopeUrl);
return true;
});
function sanitizeCredentials(value: unknown): StreamCredentialHeaders | null {
if (typeof value !== 'object' || value === null) {
return null;
}
const { cookie, authorization } = value as Record<string, unknown>;
return {
authorization:
typeof authorization === 'string' ? authorization : undefined,
cookie: typeof cookie === 'string' ? cookie : undefined,
};
}
ipcMain.handle(
'set-user-agent',
(_event, userAgent, referer, scopeUrl, credentials) => {
setUserAgent(
userAgent,
referer,
scopeUrl,
sanitizeCredentials(credentials)
);
return true;
}
);
/**
* Sets scoped request headers for the currently selected stream.
* @param userAgent user agent to use
* @param referer referer to use
* @param scopeUrl stream URL used to limit the override to the active origin
* @param credentials portal Cookie/Authorization for auth-gated streams;
* applied only to the exact origin of `scopeUrl` and only while the
* scoped override is active
*/
export function setUserAgent(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
scopeUrl?: string | null,
credentials?: StreamCredentialHeaders | null
): void {
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
configureRequestHeaderOverride(userAgent, referer, scopeUrl, credentials);
}
@@ -339,3 +339,220 @@ describe('request header overrides', () => {
});
});
});
/**
* Portal credentials (Cookie/Authorization) in the scoped override — the
* mechanism that lets built-in players play auth-gated portal streams. The
* security contract pinned here: credentials apply ONLY to the exact stream
* origin, never ride on the broader UA/Referer scope, never enter the
* unscoped (playlist-level) layer, and are dropped when the scoped override
* is cleared or replaced.
*/
describe('request header override credentials', () => {
const STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const SEGMENT_URL = 'http://portal.example:8080/live/segment-1.ts';
const CREDENTIALS = {
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
};
beforeEach(() => {
jest.resetModules();
mockOnBeforeSendHeaders.mockClear();
});
it('attaches cookie and authorization to requests on the stream origin', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
expect(headers['User-Agent']).toBe('MAG250');
expect(headers['Referer']).toBe('http://portal.example');
});
it('does not attach credentials to the referer origin', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
// The UA/Referer scope includes the referer origin (port 80), but
// the credentials belong to the stream origin (:8080) only.
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'http://portal.example/some/page'
);
expect(headers['User-Agent']).toBe('MAG250');
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('never attaches credentials to a third-party host', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'http://cdn.other-host.example/seg.ts',
{ Accept: '*/*' }
);
expect(headers).toEqual({ Accept: '*/*' });
});
it('ignores credentials passed without a scope URL', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
undefined,
undefined,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['User-Agent']).toBe('PlaylistAgent/1.0');
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('drops credentials when the next stream replaces the scoped override', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
configureRequestHeaderOverride(
'OtherAgent/1.0',
'http://other.example',
'http://other.example/stream.m3u8'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('clears the credentialed override when playback ends', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
// The renderer's playback-end clear: empty values with a scope URL.
configureRequestHeaderOverride(undefined, undefined, STREAM_URL);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL, {
Accept: '*/*',
});
expect(headers).toEqual({ Accept: '*/*' });
});
it('keeps a credentials-only override active without UA or referer', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
undefined,
undefined,
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
});
it('replaces existing credential headers case-insensitively', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride(
'MAG250',
'http://portal.example',
STREAM_URL,
CREDENTIALS
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL, {
authorization: 'Bearer STALE',
cookie: 'stale=1',
});
expect(headers['Cookie']).toBe(CREDENTIALS.cookie);
expect(headers['cookie']).toBeUndefined();
expect(headers['Authorization']).toBe(CREDENTIALS.authorization);
expect(headers['authorization']).toBeUndefined();
});
it('rejects credential values containing control characters', async () => {
const { configureRequestHeaderOverride } = await import(
'./request-header-overrides.service'
);
configureRequestHeaderOverride('MAG250', undefined, STREAM_URL, {
authorization: 'Bearer TOKEN\r\nX-Injected: 1',
cookie: 'mac=00\r\nX-Injected: 1',
});
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(listener, SEGMENT_URL);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
expect(headers['X-Injected']).toBeUndefined();
});
});
@@ -1,6 +1,19 @@
import { session } from 'electron';
export type StreamCredentialHeaders = {
authorization?: string | null;
cookie?: string | null;
};
type HeaderOverride = {
authorization?: string;
cookie?: string;
/**
* Origin the credentials belong to — always the stream URL's own origin.
* Cookie/Authorization are attached only on an exact match, never on the
* broader `scopeOrigins` set that User-Agent/Referer/Origin use.
*/
credentialOrigin?: string;
origin?: string;
referer?: string;
scopeOrigins?: Set<string>;
@@ -29,8 +42,19 @@ let activeScopedHeaderOverride: HeaderOverride | null = null;
let listenerRegistered = false;
function normalizeHeaderValue(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
// A control character in a header value is never legitimate and could
// otherwise smuggle extra headers into the raw request.
// eslint-disable-next-line no-control-regex
if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) {
return undefined;
}
return trimmed;
}
function getOrigin(value?: string | null): string | undefined {
@@ -116,6 +140,8 @@ function handleBeforeSendHeaders(
return;
}
const requestOrigin = getOrigin(details.url);
for (const override of overrides) {
if (override.userAgent) {
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
@@ -128,6 +154,25 @@ function handleBeforeSendHeaders(
if (override.origin) {
setRequestHeader(requestHeaders, 'Origin', override.origin);
}
// Portal credentials are attached only to requests going to the
// stream's own origin — never to a referer-origin sibling and never
// to third-party hosts a manifest may point at.
const credentialsApply =
Boolean(override.credentialOrigin) &&
requestOrigin === override.credentialOrigin;
if (credentialsApply && override.cookie) {
setRequestHeader(requestHeaders, 'Cookie', override.cookie);
}
if (credentialsApply && override.authorization) {
setRequestHeader(
requestHeaders,
'Authorization',
override.authorization
);
}
}
callback({ requestHeaders });
@@ -148,13 +193,33 @@ function ensureHeaderOverrideListener(): void {
export function configureRequestHeaderOverride(
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
scopeUrl?: string | null,
credentials?: StreamCredentialHeaders | null
): void {
const normalizedUserAgent = normalizeHeaderValue(userAgent);
const normalizedReferer = normalizeHeaderValue(referer);
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
const scopeOrigin = getOrigin(scopeUrl);
// Credentials are portal secrets: they require a scoped override whose
// stream URL yields a concrete origin to pin them to. Anything else is
// dropped rather than applied broadly (fail closed). They live only in
// this in-memory override — never in the session cookie jar and never on
// disk — so they cannot outlive the app process.
const normalizedCookie =
isScopedOverride && scopeOrigin
? normalizeHeaderValue(credentials?.cookie)
: undefined;
const normalizedAuthorization =
isScopedOverride && scopeOrigin
? normalizeHeaderValue(credentials?.authorization)
: undefined;
if (!normalizedUserAgent && !normalizedReferer) {
if (
!normalizedUserAgent &&
!normalizedReferer &&
!normalizedCookie &&
!normalizedAuthorization
) {
if (isScopedOverride) {
clearScopedRequestHeaderOverride();
} else {
@@ -164,7 +229,6 @@ export function configureRequestHeaderOverride(
}
const refererOrigin = getOrigin(normalizedReferer);
const scopeOrigin = getOrigin(scopeUrl);
const override: HeaderOverride = {
origin: refererOrigin,
referer: normalizedReferer,
@@ -177,6 +241,11 @@ export function configureRequestHeaderOverride(
Boolean(origin)
)
);
if (normalizedCookie || normalizedAuthorization) {
override.credentialOrigin = scopeOrigin;
override.cookie = normalizedCookie;
override.authorization = normalizedAuthorization;
}
activeScopedHeaderOverride = override;
} else {
activeHeaderOverride = override;
@@ -52,4 +52,58 @@ describe('stalker playback context', () => {
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
it('keeps the portal profile for a same-host stream on another port', () => {
// Must match the renderer's classification: a same-host stream on a
// different port stays portal-owned, or isStalkerDirectStreamProfile
// would discard the renderer's credentialed headers for it.
const streamUrl = 'http://same-host.example.test:8080/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'http://same-host.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['Cookie']).toContain(`mac=${macAddress}`);
expect(headers['Authorization']).toBe('Bearer token-1');
expect(headers['User-Agent']).not.toBe('KSPlayer');
});
it('uses the credential-free direct profile for foreign hosts', () => {
const streamUrl = 'http://cdn.foreign.example.test/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'http://portal.foreign-case.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['User-Agent']).toBe('KSPlayer');
expect(headers).not.toHaveProperty('Cookie');
expect(headers).not.toHaveProperty('Authorization');
});
it('uses the credential-free profile on an https→http downgrade', () => {
const streamUrl = 'http://downgrade.example.test/stream/1.ts';
rememberStalkerPlaybackContext({
streamUrl,
portalUrl:
'https://downgrade.example.test/stalker_portal/server/load.php',
macAddress,
token: 'token-1',
});
const headers = getStalkerPlaybackContextHeaders(streamUrl) ?? {};
expect(headers['User-Agent']).toBe('KSPlayer');
expect(headers).not.toHaveProperty('Cookie');
expect(headers).not.toHaveProperty('Authorization');
});
});
@@ -1,5 +1,6 @@
import {
buildStalkerSerialCfduid,
isStalkerStreamCredentialSafe,
normalizeStalkerSerialNumber,
} from '@iptvnator/shared/interfaces';
@@ -34,14 +35,20 @@ function normalizeStreamUrl(streamUrl: string): string {
}
}
function getOrigin(streamUrl: string): string {
function unwrapStreamUrl(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
return spaceIndex > 0
? normalized.slice(spaceIndex + 1).trim()
: normalized;
}
function getOrigin(streamUrl: string): string {
const unwrapped = unwrapStreamUrl(streamUrl);
if (!unwrapped) return '';
try {
return new URL(maybeWrapped).origin;
return new URL(unwrapped).origin;
} catch {
return '';
}
@@ -91,10 +98,17 @@ export function rememberStalkerPlaybackContext(input: {
portalOrigin = undefined;
}
// Classified by the shared predicate so this fallback context can never
// disagree with the renderer's header builder: same host (port change or
// scheme upgrade included) keeps the portal profile, a foreign host or
// an https→http downgrade gets the credential-free direct profile.
const crossOriginStream =
Boolean(streamOrigin) &&
Boolean(portalOrigin) &&
streamOrigin !== portalOrigin;
!isStalkerStreamCredentialSafe(
input.portalUrl,
unwrapStreamUrl(input.streamUrl)
);
const headers: Record<string, string> = crossOriginStream
? {
@@ -1,6 +1,8 @@
import { Request, Response } from 'express';
import { resolveStreamUrl } from '../data-generator.js';
import { buildRequestOrigin } from '../marketing-poster-url.js';
import { extractMac } from '../request-mac.js';
import { getScenario } from '../scenarios.js';
/**
* Stalker create_link — returns a playable stream URL.
@@ -18,7 +20,17 @@ export function handleCreateLink(req: Request, res: Response): void {
const itemIndex = cmd
.split('')
.reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
const streamUrl = resolveStreamUrl(cmd, itemIndex);
// The /stalker proxy route dispatches a synthetic request without
// Express' .get(), so fall back to the Host header there instead of
// crashing — the gated scenario is only meaningful for direct clients
// that can attach credentials to media requests anyway.
const requestOrigin =
typeof req.get === 'function'
? buildRequestOrigin(req)
: `http://${req.headers['host'] ?? 'localhost:3210'}`;
const streamUrl = getScenario(mac).gatedStream
? `${requestOrigin}/stream/gated/video.mp4`
: resolveStreamUrl(cmd, itemIndex);
console.log(`[create_link] MAC=${mac} cmd=${cmd} → ${streamUrl}`);
@@ -25,6 +25,13 @@ export interface ScenarioConfig {
marketingFixture?: true;
/** Answer `get_profile` with `status: 2` until `auth_second_step=1`. */
requiresLogin?: true;
/**
* `create_link` returns this server's own `/stream/gated/video.mp4`,
* which answers 403 unless the request carries the portal mac cookie AND
* the MAC's Bearer token — proves a player's media requests really carry
* the portal credentials (the "only VLC works" cluster).
*/
gatedStream?: true;
}
/**
@@ -136,6 +143,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
embeddedSeriesFraction: 0,
marketingFixture: true,
},
'00:1a:79:00:00:09': {
name: 'gated-stream',
description:
'Streams gated on portal credentials — create_link returns a ' +
'local URL that 403s without the mac cookie + Bearer token',
seed: 9009,
categoryCount: { itv: 2, radio: 1, vod: 1, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
gatedStream: true,
},
};
/**
+42 -1
View File
@@ -4,7 +4,11 @@ import express, { Request, Response } from 'express';
import cors from 'cors';
import portalRouter, { createPortalRouter } from './app/routes/portal.route.js';
import dispatchPortalAction from './app/routes/dispatch.js';
import { invalidateSession, resetAuthState } from './app/auth-store.js';
import {
checkRequestAuthorization,
invalidateSession,
resetAuthState,
} from './app/auth-store.js';
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
import { resetAll, resetMac } from './app/data-store.js';
import { SCENARIOS } from './app/scenarios.js';
@@ -172,6 +176,43 @@ app.get('/stalker', (req: Request, res: Response) => {
res.json({ payload: plainTextBody ?? captured });
});
/**
* 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
* requests carry the portal credentials — a unit test cannot show that a
* header reached the video element.
*
* The body is the shared clear (non-DRM) fragmented-MP4 fixture from the
* DASH e2e suite; `sendFile` supplies Range support for progressive playback.
*/
const GATED_STREAM_FIXTURE = join(
process.cwd(),
'apps/web-e2e/src/fixtures/dash/clear-video.mp4'
);
app.get('/stream/gated/video.mp4', (req: Request, res: Response) => {
const failure = checkRequestAuthorization(req, true);
if (failure) {
console.log(
`[gated-stream] 403 (${failure}) cookie=${String(
req.headers['cookie'] ?? '<none>'
)} auth=${req.headers['authorization'] ? 'present' : '<none>'}`
);
res.status(403).type('text/plain').send(failure);
return;
}
// `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, {
dotfiles: 'allow',
headers: { 'Content-Type': 'video/mp4' },
});
});
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
+41 -5
View File
@@ -122,8 +122,9 @@ policy.
## Scoped Request Header Overrides
Inline playback can request temporary `User-Agent`, `Referer`, and `Origin`
header overrides through `window.electron.setUserAgent(userAgent, referer,
scopeUrl)`.
header overrides — and, for auth-gated portal streams, `Cookie` and
`Authorization` credentials — through `window.electron.setUserAgent(userAgent,
referer, scopeUrl, credentials?)`.
The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts`
and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`.
@@ -131,11 +132,18 @@ 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
narrower call would overwrite the credentialed override.
Rules:
- empty playlist-level `userAgent` and `referer` clear all active overrides
- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only
the scoped channel override, preserving playlist-level defaults
- empty channel-level values with a `scopeUrl` clear only the scoped channel
override, preserving playlist-level defaults
- channel playback should pass the stream URL as `scopeUrl`
- scoped overrides apply only to the active stream origin and referer origin
- playlist-level user agents and referrers may call the bridge without a
@@ -143,10 +151,38 @@ Rules:
the whole M3U playlist
- header names are replaced case-insensitively before canonical `User-Agent`,
`Referer`, and `Origin` names are written
- header values containing control characters are rejected outright (header
smuggling)
Credential rules (`credentials.cookie` / `credentials.authorization`) are
deliberately stricter than the general scope:
- credentials are accepted **only** with a `scopeUrl` that parses to a
concrete origin; an unscoped (playlist-level) call silently drops them —
fail closed, never fail broad
- they are attached **only** to requests whose origin equals the stream URL's
exact origin — never to the referer-origin sibling that `User-Agent`/`Referer`
also cover, and never to third-party hosts an HLS manifest may point at
- they live only in the in-memory override: never in the session cookie jar,
never on disk, so they cannot outlive the app process
- they are dropped whenever the scoped override is replaced (channel change)
or cleared (playback end, `WebPlayerViewComponent` destroy)
The header-injection design was chosen over `session.cookies.set()`
deliberately: jar cookies only attach to credentialed requests, which would
force `withCredentials` into every web engine and break against the
`Access-Control-Allow-Origin: *` that IPTV panels typically send, and jar
scoping is domain-based (port-blind) — weaker than the exact-origin match
above. Injecting at `onBeforeSendHeaders` sits below the CORS/credentials
layer, so the request stays "uncredentialed" for the fetch spec while the
wire request carries the portal session.
When changing this flow, keep stale header cleanup covered. Switching from a
channel or playlist with custom headers to one without custom headers must clear
the previous override.
the previous override. The Electron e2e
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` pins the
end-to-end contract against a mock stream that answers 403 without the portal
credentials.
## Main-Process Remote Requests
+43
View File
@@ -183,6 +183,49 @@ the cross-portal collection resolver (`StreamResolverService`) use
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
replies against the portal base URL.
## Playback Header Contract
Every playback kind — ITV, VOD, series episodes, and radio — resolves its
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").
Two stream profiles exist, selected by one shared predicate:
- **Portal-owned** (`isStalkerStreamCredentialSafe()` in
`@iptvnator/shared/interfaces`): the stream host equals the portal host —
including a different port or an http→https upgrade, the routine IPTV panel
shape (#1158 class). These streams get the full MAG profile: `Cookie`
(`mac=…` plus protocol cookies), `Authorization: Bearer <token>` when a
session token exists, `User-Agent` (playlist override or the MAG UA — the
API path always sent both, the playback set historically sent only
`X-User-Agent`), `X-User-Agent`, `SN` when a real serial exists, and
`Origin`/`Referer` set to the portal origin.
- **Foreign / direct** (different host, or an https→http downgrade): the
credential-free `KSPlayer` direct-stream profile (`User-Agent: KSPlayer`,
`Accept`, `Range`, `Icy-MetaData`, `Connection`). Portal credentials must
never reach a third-party host; direct stream URLs carry their access token
in the URL minted by `create_link`.
The Electron main process keeps a fallback header context per resolved
`create_link` URL (`stalker-playback-context.service.ts`) for external-player
launches that arrive without renderer headers. It classifies streams with the
same shared predicate — if the two ever diverged,
`isStalkerDirectStreamProfile` in the external-player path would discard the
renderer's credentialed headers for streams the main process misread as
direct.
The mock server's `gated-stream` scenario (MAC `00:1A:79:00:00:09`) makes
`create_link` return a local `/stream/gated/video.mp4` that answers 403
without the mac cookie and current Bearer token;
`apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts` uses it to
prove a built-in player's media requests really carry the credentials.
## Live TV and Radio
The Stalker live route and radio route intentionally share
@@ -1,5 +1,10 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { buildStalkerExternalPlaybackHeaders } from './stalker-live-playback.utils';
import {
STALKER_MAG_USER_AGENT,
STALKER_STREAM_USER_AGENT,
buildStalkerExternalPlaybackHeaders,
isCrossOriginStalkerStream,
} from './stalker-live-playback.utils';
import { STALKER_SERIAL_NUMBER } from './stalker-session.service';
function createPlaylist(
@@ -63,4 +68,118 @@ describe('buildStalkerExternalPlaybackHeaders', () => {
const cfduid = headers['Cookie']?.match(/__cfduid=([^;]+)/)?.[1];
expect(cfduid).toHaveLength(32);
});
it('sends the MAG User-Agent alongside X-User-Agent for portal-owned streams', () => {
// The API request path always sent both; the playback header set
// previously carried only X-User-Agent (audit finding 5).
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist(),
'TOKEN',
'http://portal.test/live/ch1.ts'
);
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
expect(headers['Authorization']).toBe('Bearer TOKEN');
});
it('lets a playlist-level custom User-Agent take precedence over the MAG UA', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({ userAgent: 'CustomAgent/9.9' }),
'TOKEN',
'http://portal.test/live/ch1.ts'
);
expect(headers['User-Agent']).toBe('CustomAgent/9.9');
expect(headers['X-User-Agent']).toBe(STALKER_MAG_USER_AGENT);
});
it('keeps portal credentials for a same-host stream on another port', () => {
// Panels routinely serve streams from :8080 next to the portal on
// :80, and exactly those streams are gated on the portal cookie
// (#1158 class) — a strict origin comparison used to push them onto
// the credential-free KSPlayer profile.
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist(),
'TOKEN',
'http://portal.test:8080/live/ch1.ts'
);
expect(headers['Cookie']).toContain('mac=00:1A:79:AA:BB:CC');
expect(headers['Authorization']).toBe('Bearer TOKEN');
expect(headers['User-Agent']).toBe(STALKER_MAG_USER_AGENT);
});
it('uses the credential-free direct profile for foreign hosts', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist(),
'TOKEN',
'http://cdn.other.test/live/ch1.ts'
);
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
it('uses the credential-free profile on an https→http downgrade', () => {
const headers = buildStalkerExternalPlaybackHeaders(
createPlaylist({
portalUrl: 'https://portal.test/stalker_portal/c/index.html',
}),
'TOKEN',
'http://portal.test/live/ch1.ts'
);
expect(headers['User-Agent']).toBe(STALKER_STREAM_USER_AGENT);
expect(headers['Cookie']).toBeUndefined();
expect(headers['Authorization']).toBeUndefined();
});
});
describe('isCrossOriginStalkerStream', () => {
it('treats same-host port and scheme-upgrade changes as portal-owned', () => {
const playlist = createPlaylist();
expect(
isCrossOriginStalkerStream(
playlist,
'http://portal.test:8080/live/ch1.ts'
)
).toBe(false);
expect(
isCrossOriginStalkerStream(
playlist,
'https://portal.test/live/ch1.ts'
)
).toBe(false);
});
it('treats foreign hosts and TLS downgrades as cross-origin', () => {
expect(
isCrossOriginStalkerStream(
createPlaylist(),
'http://cdn.other.test/live/ch1.ts'
)
).toBe(true);
expect(
isCrossOriginStalkerStream(
createPlaylist({
portalUrl:
'https://portal.test/stalker_portal/c/index.html',
}),
'http://portal.test/live/ch1.ts'
)
).toBe(true);
});
it('returns false when the playlist or stream URL is missing', () => {
expect(isCrossOriginStalkerStream(undefined, 'http://x.test/1')).toBe(
false
);
expect(isCrossOriginStalkerStream(createPlaylist(), undefined)).toBe(
false
);
});
});
@@ -1,4 +1,7 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
isStalkerStreamCredentialSafe,
} from '@iptvnator/shared/interfaces';
import {
buildStalkerSerialCfduid,
normalizeStalkerSerialNumber,
@@ -23,20 +26,24 @@ export function getStalkerPortalOrigin(
}
}
/**
* True when the stream must be treated as foreign to the portal — a
* different HOST or an https→http downgrade — and therefore must not carry
* the portal's credentials. A same-host stream on another port or an
* upgraded scheme stays portal-owned: IPTV panels routinely serve streams
* from `:8080` next to the portal on `:80`, and those are exactly the
* streams gated on the portal's mac cookie/token (#1158 class; curl
* semantics, matching the validated redirect layer).
*/
export function isCrossOriginStalkerStream(
playlist: PlaylistMeta | undefined | null,
streamUrl?: string
): boolean {
const portalOrigin = getStalkerPortalOrigin(playlist);
if (!portalOrigin || !streamUrl) {
if (!playlist?.portalUrl || !streamUrl) {
return false;
}
try {
return new URL(streamUrl).origin !== portalOrigin;
} catch {
return false;
}
return !isStalkerStreamCredentialSafe(playlist.portalUrl, streamUrl);
}
export function buildStalkerExternalPlaybackHeaders(
@@ -48,6 +55,10 @@ export function buildStalkerExternalPlaybackHeaders(
return {};
}
// Foreign-host (or TLS-downgraded) streams get the credential-free
// KSPlayer direct-stream profile: their access token travels in the URL
// that create_link minted, and the portal's mac cookie/Bearer token must
// never reach a third-party host.
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
return {
'User-Agent': STALKER_STREAM_USER_AGENT,
@@ -71,8 +82,14 @@ export function buildStalkerExternalPlaybackHeaders(
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
}
// Both the real User-Agent and Stalker's X-User-Agent, matching what the
// API request path sends — a portal that filters streams on the MAG UA
// never saw it here before (audit finding: same-origin set only
// X-User-Agent). A playlist-level custom UA keeps precedence.
const magUserAgent = playlist.userAgent?.trim() || STALKER_MAG_USER_AGENT;
const headers: Record<string, string> = {
Cookie: cookieParts.join('; '),
'User-Agent': magUserAgent,
'X-User-Agent': STALKER_MAG_USER_AGENT,
};
@@ -284,4 +284,87 @@ describe('withStalkerPlayer', () => {
})
);
});
it('attaches the portal header set to VOD playback on the portal host', async () => {
const session = TestBed.inject(StalkerSessionService) as unknown as {
getCachedToken: jest.Mock;
};
session.getCachedToken.mockReturnValue('TOKEN99');
// Same host as the portal, different port — the #1158-class stream
// shape that is gated on the portal cookie.
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
.mockResolvedValueOnce({
js: { cmd: 'ffmpeg http://demo.example:8080/video_77.mpg' },
});
const playback = await store.resolveVodPlayback(
undefined,
'Movie Title',
'thumb.jpg'
);
expect(session.getCachedToken).toHaveBeenCalledWith(PLAYLIST._id);
expect(playback.headers?.['Cookie']).toContain(
'mac=00:1A:79:00:00:01'
);
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
expect(playback.headers?.['X-User-Agent']).toBeDefined();
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
expect(playback.referer).toBe('http://demo.example');
expect(playback.origin).toBe('http://demo.example');
});
it('keeps VOD playback from a foreign CDN credential-free', async () => {
const session = TestBed.inject(StalkerSessionService) as unknown as {
getCachedToken: jest.Mock;
};
session.getCachedToken.mockReturnValue('TOKEN99');
dataService.sendIpcEvent
.mockResolvedValueOnce({ js: { data: [{ id: 77 }] } })
.mockResolvedValueOnce({
js: { cmd: 'ffmpeg http://cdn.example/video_77.mpg' },
});
const playback = await store.resolveVodPlayback(
undefined,
'Movie Title',
'thumb.jpg'
);
expect(playback.headers?.['Cookie']).toBeUndefined();
expect(playback.headers?.['Authorization']).toBeUndefined();
expect(playback.headers?.['User-Agent']).toBe('KSPlayer');
expect(playback.referer).toBeUndefined();
expect(playback.origin).toBeUndefined();
});
it('attaches the portal header set to radio playback resolved from the portal', async () => {
const session = TestBed.inject(StalkerSessionService) as unknown as {
getCachedToken: jest.Mock;
};
session.getCachedToken.mockReturnValue('TOKEN99');
store.setSelectedContentType('radio');
const radioItem = {
id: 'radio-2',
cmd: '/media/radio_2.mpg',
name: 'Portal FM',
o_name: 'Portal FM',
logo: 'portal-fm.png',
category_id: '4001',
};
store.setSelectedItem(radioItem);
dataService.sendIpcEvent.mockResolvedValueOnce({
js: { cmd: 'ffmpeg http://demo.example/radio_2.mpg' },
});
const playback = await store.resolveRadioPlayback(radioItem);
expect(playback.headers?.['Cookie']).toContain(
'mac=00:1A:79:00:00:01'
);
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99');
expect(playback.referer).toBe('http://demo.example');
expect(playback.origin).toBe('http://demo.example');
});
});
@@ -196,14 +196,38 @@ export function withStalkerPlayer() {
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
// VOD and series streams sit behind the same portal gate
// as ITV: without the mac cookie/Bearer token an
// auth-enforcing portal answers 403 (they previously
// carried no portal headers at all — audit finding 5).
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
token,
streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
return {
streamUrl,
title: title ?? '',
thumbnail,
startTime,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
headers,
userAgent:
headers['User-Agent'] ||
playlist.userAgent ||
STALKER_MAG_USER_AGENT,
referer: crossOriginStream
? undefined
: playlist.referrer || portalOrigin,
origin: crossOriginStream
? undefined
: playlist.origin || portalOrigin,
contentInfo: {
playlistId: playlist._id,
contentXtreamId:
@@ -319,13 +343,36 @@ export function withStalkerPlayer() {
item.o_name || item.name || item.title
);
// Radio streams come off the same portal as ITV and are
// gated the same way — give them the identical header set
// (they previously carried no portal headers at all).
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
token,
streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
return {
streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
headers,
userAgent:
headers['User-Agent'] ||
playlist.userAgent ||
STALKER_MAG_USER_AGENT,
referer: crossOriginStream
? undefined
: playlist.referrer || portalOrigin,
origin: crossOriginStream
? undefined
: playlist.origin || portalOrigin,
};
};
+1
View File
@@ -67,6 +67,7 @@ export * from './lib/xtream-vod-stream.interface';
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-identity.utils';
export * from './lib/stalker-portal-item.interface';
export * from './lib/stalker-stream-profile.util';
export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
@@ -186,6 +186,17 @@ export interface ElectronBridgeWindowState {
isFullScreen: boolean;
}
/**
* Portal credentials for an auth-gated stream, passed alongside the scoped
* header override. The main process attaches them only to requests going to
* the exact origin of the override's `scopeUrl`, keeps them in memory only,
* and drops them when the scoped override is cleared or replaced.
*/
export interface ElectronBridgeStreamCredentials {
authorization?: string | null;
cookie?: string | null;
}
/**
* A playlist file the operating system asked the app to open — a command line
* argument, a file association double-click, or macOS' `open-file` event. The
@@ -624,7 +635,8 @@ export interface ElectronBridgeApi {
setUserAgent: (
userAgent?: string | null,
referer?: string | null,
scopeUrl?: string | null
scopeUrl?: string | null,
credentials?: ElectronBridgeStreamCredentials | null
) => Promise<boolean>;
openInMpv: (
url: string,
@@ -0,0 +1,75 @@
import { isStalkerStreamCredentialSafe } from './stalker-stream-profile.util';
describe('isStalkerStreamCredentialSafe', () => {
const PORTAL = 'http://portal.example/stalker_portal/c/';
it('accepts a stream on the exact portal origin', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://portal.example/live/ch1.ts'
)
).toBe(true);
});
it('accepts a same-host stream on a different port', () => {
// The #1158 class: panels routinely serve streams from :8080 next to
// the portal on :80, and those streams are gated on the portal cookie.
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://portal.example:8080/live/ch1.ts'
)
).toBe(true);
});
it('accepts a same-host scheme upgrade (http portal → https stream)', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'https://portal.example/live/ch1.ts'
)
).toBe(true);
});
it('rejects an https→http downgrade on the same host', () => {
expect(
isStalkerStreamCredentialSafe(
'https://portal.example/stalker_portal/c/',
'http://portal.example/live/ch1.ts'
)
).toBe(false);
});
it('rejects a different host', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://cdn.other.example/live/ch1.ts'
)
).toBe(false);
});
it('compares hostnames case-insensitively', () => {
expect(
isStalkerStreamCredentialSafe(
PORTAL,
'http://PORTAL.example:8080/live/ch1.ts'
)
).toBe(true);
});
it('rejects non-HTTP(S) stream schemes', () => {
expect(
isStalkerStreamCredentialSafe(PORTAL, 'rtsp://portal.example/ch1')
).toBe(false);
});
it('fails closed on missing or unparseable URLs', () => {
expect(isStalkerStreamCredentialSafe(PORTAL, undefined)).toBe(false);
expect(isStalkerStreamCredentialSafe(PORTAL, '')).toBe(false);
expect(isStalkerStreamCredentialSafe(undefined, PORTAL)).toBe(false);
expect(isStalkerStreamCredentialSafe(PORTAL, 'not a url')).toBe(false);
expect(isStalkerStreamCredentialSafe('not a url', PORTAL)).toBe(false);
});
});
@@ -0,0 +1,44 @@
/**
* Decides whether a resolved Stalker stream URL may carry the portal's
* credentials (mac cookie, Bearer token, serial-number headers).
*
* Both the renderer playback-header builder and the Electron main-process
* playback-context fallback classify streams with this single predicate; if
* the two ever disagreed, `isStalkerDirectStreamProfile` in the external
* player path would silently discard the caller's credentialed headers.
*
* The rule mirrors the transport-security carve-out of the validated
* redirect layer (docs/architecture/electron-security.md): IPTV portals
* routinely hand out stream URLs on the same host but a different port or an
* upgraded scheme, and losing the session cookie/token there breaks playback
* outright (#1158, #849, #910). A different host would hand provider
* credentials to a third party, and an https→http downgrade would replay a
* TLS-obtained session in cleartext — both stay credential-free.
*/
export function isStalkerStreamCredentialSafe(
portalUrl: string | undefined | null,
streamUrl: string | undefined | null
): boolean {
if (!portalUrl || !streamUrl) {
return false;
}
let portal: URL;
let stream: URL;
try {
portal = new URL(portalUrl);
stream = new URL(streamUrl);
} catch {
return false;
}
if (stream.protocol !== 'http:' && stream.protocol !== 'https:') {
return false;
}
if (portal.hostname.toLowerCase() !== stream.hostname.toLowerCase()) {
return false;
}
return !(portal.protocol === 'https:' && stream.protocol === 'http:');
}
@@ -124,7 +124,7 @@ describe('HtmlVideoPlayerComponent', () => {
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
});
it('passes channel headers and stream URL to Electron header overrides', () => {
it('does not configure Electron header overrides itself — WebPlayerViewComponent owns them', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
@@ -145,39 +145,9 @@ describe('HtmlVideoPlayerComponent', () => {
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'ChannelAgent/1.0',
'https://portal.example/referrer',
'https://stream.example/video.mp4'
);
});
it('clears Electron header overrides for channels without custom headers', () => {
jest.spyOn(
component.videoPlayer.nativeElement,
'load'
).mockImplementation(() => undefined);
jest.spyOn(
component.videoPlayer.nativeElement,
'play'
).mockResolvedValue(undefined);
component.playChannel({
...TEST_CHANNEL,
http: {
'user-agent': '',
origin: '',
referrer: '',
},
radio: 'false',
url: 'https://stream.example/video.mp4',
});
expect(electronApi.setUserAgent).toHaveBeenCalledWith(
'',
'',
'https://stream.example/video.mp4'
);
// A second three-header call from here would overwrite the richer
// scoped override (incl. Cookie/Authorization) the host configured.
expect(electronApi.setUserAgent).not.toHaveBeenCalled();
});
it('replaces and reloads native video sources when switching episodes', () => {
@@ -187,18 +187,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
const url = channel.url + (channel.epgParams ?? '');
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
void window.electron
?.setUserAgent(
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
)
.catch((error: unknown) => {
console.warn(
'[HtmlVideoPlayer] Failed to configure Electron request headers:',
error
);
});
// The scoped Electron header override is owned by
// WebPlayerViewComponent, which configures the full header set
// (incl. portal Cookie/Authorization) before this component
// receives the channel. Re-issuing the three-header call here
// would overwrite that richer override.
if (extension === 'mpd') {
debugHtmlPlayer(
@@ -1,59 +1,68 @@
@if (selectedPlayer() === 'videojs') {
@defer (on immediate) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
<!-- The source hand-off is deferred until the scoped Electron header
override is configured; mounting the player before `vjsOptions`
exists would fire a source-less (or credential-less) first load. -->
@if (vjsOptions) {
<app-vjs-player
[options]="vjsOptions"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'html5') {
@defer (on immediate) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (channel) {
<app-html-video-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
} @else if (selectedPlayer() === 'artplayer') {
@defer (on immediate) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
@if (channel) {
<app-art-player
[channel]="$any(channel)"
[mediaTitle]="resolvedMediaTitle()"
[volume]="volume()"
[showCaptions]="showCaptions()"
[isLive]="resolvedIsLive()"
[interactionEnabled]="playbackInteractionEnabled()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
(timeUpdate)="timeUpdate.emit($event)"
(playbackIssue)="handlePlaybackIssue($event)"
(playbackEnded)="playbackEnded.emit()"
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
/>
}
} @placeholder {
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
}
@@ -913,6 +913,122 @@ describe('WebPlayerViewComponent', () => {
)
).toBeNull();
});
describe('Electron scoped header override ownership', () => {
const GATED_STREAM_URL = 'http://portal.example:8080/live/ch1.ts';
const GATED_PLAYBACK = {
streamUrl: GATED_STREAM_URL,
title: 'Gated Channel',
isLive: true,
headers: {
'User-Agent': 'MAG250',
Referer: 'http://portal.example',
Cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
Authorization: 'Bearer TOKEN123',
},
};
let setUserAgent: jest.Mock;
beforeEach(() => {
setUserAgent = jest.fn().mockResolvedValue(true);
(window as unknown as { electron?: unknown }).electron = {
setUserAgent,
};
});
afterEach(() => {
fixture.destroy();
delete (window as unknown as { electron?: unknown }).electron;
});
it('configures the full header set — incl. credentials — before handing the source to the player', async () => {
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
// The source is handed over only after the override IPC resolves,
// so the first media request already carries the credentials.
expect(setUserAgent).toHaveBeenCalledWith(
'MAG250',
'http://portal.example',
GATED_STREAM_URL,
{
authorization: 'Bearer TOKEN123',
cookie: 'mac=00%3A1A%3A79%3A00%3A00%3A01; stb_lang=en_US',
}
);
expect(component.channel).toBeUndefined();
await fixture.whenStable();
fixture.detectChanges();
expect(component.channel.url).toBe(GATED_STREAM_URL);
});
it('omits the credentials object when the playback carries none', async () => {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live/plain.m3u8',
title: 'Plain Channel',
userAgent: 'PlainAgent/1.0',
});
fixture.detectChanges();
await fixture.whenStable();
expect(setUserAgent).toHaveBeenCalledWith(
'PlainAgent/1.0',
undefined,
'https://example.com/live/plain.m3u8',
undefined
);
});
it('applies only the newest playback when a switch supersedes a pending header IPC', async () => {
const resolvers: Array<() => void> = [];
setUserAgent.mockImplementation(
() =>
new Promise<boolean>((resolve) =>
resolvers.push(() => resolve(true))
)
);
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
fixture.componentRef.setInput('playback', {
streamUrl: 'http://portal.example:8080/live/ch2.ts',
title: 'Next Channel',
isLive: true,
headers: GATED_PLAYBACK.headers,
});
fixture.detectChanges();
// The stale IPC completion must not hand the old source over.
resolvers[0]();
await fixture.whenStable();
expect(component.channel).toBeUndefined();
resolvers[1]();
await fixture.whenStable();
expect(component.channel.url).toBe(
'http://portal.example:8080/live/ch2.ts'
);
});
it('clears the scoped override on destroy so credentials do not outlive playback', async () => {
fixture.componentRef.setInput('playback', GATED_PLAYBACK);
fixture.detectChanges();
await fixture.whenStable();
setUserAgent.mockClear();
fixture.destroy();
expect(setUserAgent).toHaveBeenCalledWith(
undefined,
undefined,
GATED_STREAM_URL
);
});
});
});
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
@@ -1,5 +1,6 @@
import {
Component,
OnDestroy,
Signal,
ViewEncapsulation,
computed,
@@ -89,7 +90,7 @@ function resolveWebPlayerSharedControls(): boolean {
],
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent {
export class WebPlayerViewComponent implements OnDestroy {
storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
@@ -221,18 +222,111 @@ export class WebPlayerViewComponent {
});
readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? '');
/** 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;
constructor() {
effect(() => {
// Track player changes so stale browser diagnostics are cleared on switch.
this.selectedPlayer();
const playback = this.resolvedPlayback();
const isLive = this.resolvedIsLive();
this.playbackDiagnostic.set(null);
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
void 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);
}
}
/**
* Configures the scoped Electron request headers BEFORE the stream source
* is handed to a player, so the very first media request already carries
* them — an auth-gated portal stream answers 403 without its
* Cookie/Authorization, and several engines treat that first failure as
* fatal. In the PWA there is no header bridge and the source applies
* synchronously, exactly as before.
*/
private applyPlayback(
playback: ResolvedPortalPlayback,
isLive: boolean
): void {
const sequence = ++this.playbackSyncSequence;
const headerSync = this.syncElectronStreamHeaders(playback);
const handOff = (): void => {
this.setChannel(playback);
this.setVjsOptions(playback.streamUrl, isLive);
};
if (!headerSync) {
handOff();
return;
}
void headerSync.then(() => {
if (sequence === this.playbackSyncSequence) {
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 =