diff --git a/.changes/playback-stalker-stream-credentials.md b/.changes/playback-stalker-stream-credentials.md new file mode 100644 index 000000000..85159ebd5 --- /dev/null +++ b/.changes/playback-stalker-stream-credentials.md @@ -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. diff --git a/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts new file mode 100644 index 000000000..9884dc501 --- /dev/null +++ b/apps/electron-backend-e2e/src/stalker-playback-headers.e2e.ts @@ -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); + } +}); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 7397dbe61..9f29f65d3 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -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', + } ); }); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index e62eaaf3b..36379161e 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/shared.events.ts b/apps/electron-backend/src/app/events/shared.events.ts index 61ff34bdb..3c1bcef21 100644 --- a/apps/electron-backend/src/app/events/shared.events.ts +++ b/apps/electron-backend/src/app/events/shared.events.ts @@ -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; + 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); } diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts index 63ae4ce55..6ed695914 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -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(); + }); +}); diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.ts index 9376d6a88..2452f7630 100644 --- a/apps/electron-backend/src/app/services/request-header-overrides.service.ts +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.ts @@ -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; @@ -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; diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts index c2e252773..00f3afb4a 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -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'); + }); }); diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts index 1b6b3a1e7..3a343661b 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts @@ -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 = crossOriginStream ? { diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index 58bf7f5a4..46c64da5b 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -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}`); diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index cdc156716..fe5bff591 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -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 = { 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, + }, }; /** diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 61b278ec5..ded3971d0 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -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'] ?? '' + )} auth=${req.headers['authorization'] ? 'present' : ''}` + ); + 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() }); diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index a8cd3e42f..0e3e98bed 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 933a41581..218aafcb7 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 ` 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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts index c9517d0a3..4e6fdfb31 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts @@ -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 + ); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts index c7ed7d597..2cf25631e 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts @@ -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 = { Cookie: cookieParts.join('; '), + 'User-Agent': magUserAgent, 'X-User-Agent': STALKER_MAG_USER_AGENT, }; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 2ce3be38a..8e2f5f367 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -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'); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index be7d64e9f..9509aa759 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -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, }; }; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 597ab8443..11a326bef 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 44023a9cc..4d96df897 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; openInMpv: ( url: string, diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts new file mode 100644 index 000000000..57a9343cc --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.spec.ts @@ -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); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts new file mode 100644 index 000000000..ec8848748 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-stream-profile.util.ts @@ -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:'); +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 91d8a8b75..cd1f8f540 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -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', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 821667c02..4562aecfa 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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( diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 39ba43a52..5865fee59 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,59 +1,68 @@ @if (selectedPlayer() === 'videojs') { @defer (on immediate) { - + + @if (vjsOptions) { + + } } @placeholder { } } @else if (selectedPlayer() === 'html5') { @defer (on immediate) { - + @if (channel) { + + } } @placeholder { } } @else if (selectedPlayer() === 'artplayer') { @defer (on immediate) { - + @if (channel) { + + } } @placeholder { } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 8c3eccb57..ee0e90dd5 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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((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 { diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index b024a43c7..9c27d9305 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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 | 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 =