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

+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:');
}