mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(stalker): bind secret playback contexts in main
This commit is contained in:
1 parent
d5acb329e0
commit
580a056035
4 files changed
+656
-81
No files matched your search
@@ -2,6 +2,7 @@ import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
getStalkerPlaybackContextHeaders,
|
||||
rememberStalkerPlaybackContext,
|
||||
StalkerPlaybackContextService,
|
||||
} from './stalker-playback-context.service';
|
||||
|
||||
describe('stalker playback context', () => {
|
||||
@@ -52,4 +53,190 @@ describe('stalker playback context', () => {
|
||||
expect(headers).not.toHaveProperty('SN');
|
||||
expect(headers['Cookie']).not.toContain('__cfduid=');
|
||||
});
|
||||
|
||||
it('does not reuse a legacy context for another query or origin path', () => {
|
||||
const origin = 'http://legacy-context.example.test';
|
||||
rememberStalkerPlaybackContext({
|
||||
streamUrl: `${origin}/stream/1.ts?ticket=one`,
|
||||
portalUrl: `${origin}/stalker_portal/server/load.php`,
|
||||
macAddress,
|
||||
token: 'token-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
getStalkerPlaybackContextHeaders(`${origin}/stream/1.ts?ticket=two`)
|
||||
).toBeNull();
|
||||
expect(
|
||||
getStalkerPlaybackContextHeaders(`${origin}/another.ts`)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('opaque Stalker playback contexts', () => {
|
||||
let now: number;
|
||||
let nextRef: number;
|
||||
let service: StalkerPlaybackContextService;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_000;
|
||||
nextRef = 0;
|
||||
service = new StalkerPlaybackContextService({
|
||||
createRef: () => `opaque-ref-${++nextRef}`,
|
||||
now: () => now,
|
||||
});
|
||||
});
|
||||
|
||||
function register(
|
||||
overrides: Partial<
|
||||
Parameters<StalkerPlaybackContextService['register']>[0]
|
||||
> = {}
|
||||
): string {
|
||||
return service.register({
|
||||
authGeneration: 4,
|
||||
coordinatorEpoch: 7,
|
||||
headers: {
|
||||
Authorization: 'Bearer synthetic-token',
|
||||
Cookie: 'mac=02:00:00:00:00:01; session=synthetic',
|
||||
},
|
||||
leaseRef: 'lease-a',
|
||||
senderId: 10,
|
||||
sessionKey: 'session-a',
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one#ignored',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it('binds a single-use context to sender and exact normalized stream URL', () => {
|
||||
const contextRef = register();
|
||||
|
||||
expect(
|
||||
service.consume({
|
||||
contextRef,
|
||||
senderId: 11,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(
|
||||
service.consume({
|
||||
contextRef,
|
||||
senderId: 10,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=other',
|
||||
})
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
service.consume({
|
||||
contextRef,
|
||||
senderId: 10,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one',
|
||||
})
|
||||
).toEqual({
|
||||
Authorization: 'Bearer synthetic-token',
|
||||
Cookie: 'mac=02:00:00:00:00:01; session=synthetic',
|
||||
});
|
||||
expect(
|
||||
service.consume({
|
||||
contextRef,
|
||||
senderId: 10,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('expires contexts after exactly two minutes', () => {
|
||||
const contextRef = register();
|
||||
now += 120_000;
|
||||
|
||||
expect(
|
||||
service.consume({
|
||||
contextRef,
|
||||
senderId: 10,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one',
|
||||
})
|
||||
).toBeNull();
|
||||
expect(service.size).toBe(0);
|
||||
});
|
||||
|
||||
it('invalidates by lease, session, auth generation, coordinator epoch, and sender', () => {
|
||||
const leaseRef = register();
|
||||
service.invalidateLease('lease-a');
|
||||
expect(service.has(leaseRef)).toBe(false);
|
||||
|
||||
const sessionRef = register({ leaseRef: 'lease-b' });
|
||||
service.invalidateSession('session-a');
|
||||
expect(service.has(sessionRef)).toBe(false);
|
||||
|
||||
const generationRef = register({
|
||||
authGeneration: 5,
|
||||
sessionKey: 'session-b',
|
||||
});
|
||||
service.invalidateAuthGeneration('session-b', 5);
|
||||
expect(service.has(generationRef)).toBe(false);
|
||||
|
||||
const epochRef = register({
|
||||
coordinatorEpoch: 8,
|
||||
sessionKey: 'session-c',
|
||||
});
|
||||
service.invalidateCoordinatorEpoch('session-c', 8);
|
||||
expect(service.has(epochRef)).toBe(false);
|
||||
|
||||
const senderRef = register({ senderId: 12, sessionKey: 'session-d' });
|
||||
service.cleanupSender(12);
|
||||
expect(service.has(senderRef)).toBe(false);
|
||||
});
|
||||
|
||||
it('invalidates an old generation without deleting the replacement generation', () => {
|
||||
const oldRef = register({
|
||||
authGeneration: 1,
|
||||
sessionKey: 'session-shared',
|
||||
});
|
||||
const newRef = register({
|
||||
authGeneration: 2,
|
||||
sessionKey: 'session-shared',
|
||||
streamUrl: 'https://media.example.test/new.ts',
|
||||
});
|
||||
|
||||
service.invalidateAuthGeneration('session-shared', 1);
|
||||
|
||||
expect(service.has(oldRef)).toBe(false);
|
||||
expect(service.has(newRef)).toBe(true);
|
||||
});
|
||||
|
||||
it('copies headers into and out of the private registry', () => {
|
||||
const headers = { Authorization: 'Bearer original' };
|
||||
const contextRef = register({ headers });
|
||||
headers.Authorization = 'Bearer mutated';
|
||||
|
||||
const resolved = service.consume({
|
||||
contextRef,
|
||||
senderId: 10,
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=one',
|
||||
});
|
||||
expect(resolved).toEqual({ Authorization: 'Bearer original' });
|
||||
if (resolved) {
|
||||
resolved.Authorization = 'Bearer returned-mutation';
|
||||
}
|
||||
expect(JSON.stringify({ contextRef })).not.toContain('original');
|
||||
});
|
||||
|
||||
it('rejects invalid URLs, headers, refs, and binding metadata', () => {
|
||||
expect(() => register({ streamUrl: 'file:///tmp/video' })).toThrow(
|
||||
'invalid-playback-context'
|
||||
);
|
||||
expect(() =>
|
||||
register({ headers: { Cookie: 'session=one\r\nInjected: yes' } })
|
||||
).toThrow('invalid-playback-context');
|
||||
expect(() => register({ authGeneration: -1 })).toThrow(
|
||||
'invalid-playback-context'
|
||||
);
|
||||
expect(() => register({ coordinatorEpoch: -1 })).toThrow(
|
||||
'invalid-playback-context'
|
||||
);
|
||||
expect(() => register({ senderId: -1 })).toThrow(
|
||||
'invalid-playback-context'
|
||||
);
|
||||
expect(() => register({ leaseRef: '' })).toThrow(
|
||||
'invalid-playback-context'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
buildStalkerSerialCfduid,
|
||||
normalizeStalkerSerialNumber,
|
||||
@@ -7,69 +8,222 @@ const STALKER_MAG_USER_AGENT =
|
||||
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
|
||||
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
|
||||
const PLAYBACK_CONTEXT_TTL_MS = 2 * 60 * 1000;
|
||||
const MAX_CONTEXTS = 4096;
|
||||
const MAX_REF_LENGTH = 512;
|
||||
const MAX_BINDING_LENGTH = 1024;
|
||||
const MAX_URL_LENGTH = 16 * 1024;
|
||||
const MAX_HEADERS = 64;
|
||||
const MAX_HEADER_NAME_LENGTH = 256;
|
||||
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
|
||||
|
||||
const CONTEXT_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
type PlaybackContext = {
|
||||
headers: Record<string, string>;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const playbackContextByStream = new Map<string, PlaybackContext>();
|
||||
const playbackContextByOrigin = new Map<string, PlaybackContext>();
|
||||
|
||||
function normalizeStreamUrl(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;
|
||||
|
||||
try {
|
||||
const parsed = new URL(maybeWrapped);
|
||||
return `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
return maybeWrapped.split('?')[0];
|
||||
}
|
||||
export interface StalkerPlaybackContextRegistration {
|
||||
readonly authGeneration: number;
|
||||
readonly coordinatorEpoch: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly leaseRef: string;
|
||||
readonly senderId: number;
|
||||
readonly sessionKey: string;
|
||||
readonly streamUrl: string;
|
||||
}
|
||||
|
||||
function getOrigin(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;
|
||||
try {
|
||||
return new URL(maybeWrapped).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
export interface StalkerPlaybackContextConsumption {
|
||||
readonly contextRef: string;
|
||||
readonly senderId: number;
|
||||
readonly streamUrl: string;
|
||||
}
|
||||
|
||||
function removeExpiredContextEntries() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of playbackContextByStream.entries()) {
|
||||
if (now - value.createdAt > CONTEXT_TTL_MS) {
|
||||
playbackContextByStream.delete(key);
|
||||
export interface StalkerPlaybackContextServiceOptions {
|
||||
readonly createRef?: () => string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
interface OpaquePlaybackContext {
|
||||
readonly authGeneration: number;
|
||||
readonly coordinatorEpoch: number;
|
||||
readonly expiresAt: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly leaseRef: string;
|
||||
readonly senderId: number;
|
||||
readonly sessionKey: string;
|
||||
readonly streamUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-process-only, sender-bound registry for full-session playback.
|
||||
*
|
||||
* The renderer receives only the opaque key. Cookie and authorization values
|
||||
* stay in this registry and are released once to an exact player request.
|
||||
*/
|
||||
export class StalkerPlaybackContextService {
|
||||
readonly #contexts = new Map<string, OpaquePlaybackContext>();
|
||||
readonly #createRef: () => string;
|
||||
readonly #now: () => number;
|
||||
|
||||
constructor(options: StalkerPlaybackContextServiceOptions = {}) {
|
||||
this.#createRef =
|
||||
options.createRef ?? (() => randomBytes(32).toString('base64url'));
|
||||
this.#now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
this.#pruneExpired();
|
||||
return this.#contexts.size;
|
||||
}
|
||||
|
||||
register(input: StalkerPlaybackContextRegistration): string {
|
||||
this.#pruneExpired();
|
||||
assertSafeInteger(input.senderId);
|
||||
assertSafeInteger(input.authGeneration);
|
||||
assertSafeInteger(input.coordinatorEpoch);
|
||||
assertBinding(input.leaseRef);
|
||||
assertBinding(input.sessionKey);
|
||||
const streamUrl = normalizeExactStreamUrl(input.streamUrl);
|
||||
const headers = copyHeaders(input.headers);
|
||||
|
||||
if (this.#contexts.size >= MAX_CONTEXTS) {
|
||||
throw invalidContext();
|
||||
}
|
||||
|
||||
let contextRef = '';
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const candidate = this.#createRef();
|
||||
assertBinding(candidate, MAX_REF_LENGTH);
|
||||
if (!this.#contexts.has(candidate)) {
|
||||
contextRef = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!contextRef) {
|
||||
throw invalidContext();
|
||||
}
|
||||
|
||||
this.#contexts.set(contextRef, {
|
||||
authGeneration: input.authGeneration,
|
||||
coordinatorEpoch: input.coordinatorEpoch,
|
||||
expiresAt: this.#now() + PLAYBACK_CONTEXT_TTL_MS,
|
||||
headers,
|
||||
leaseRef: input.leaseRef,
|
||||
senderId: input.senderId,
|
||||
sessionKey: input.sessionKey,
|
||||
streamUrl,
|
||||
});
|
||||
return contextRef;
|
||||
}
|
||||
|
||||
consume(
|
||||
input: StalkerPlaybackContextConsumption
|
||||
): Record<string, string> | null {
|
||||
this.#pruneExpired();
|
||||
if (
|
||||
!isSafeInteger(input.senderId) ||
|
||||
!isBinding(input.contextRef, MAX_REF_LENGTH)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const context = this.#contexts.get(input.contextRef);
|
||||
if (!context || context.senderId !== input.senderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let streamUrl: string;
|
||||
try {
|
||||
streamUrl = normalizeExactStreamUrl(input.streamUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (context.streamUrl !== streamUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.#contexts.delete(input.contextRef);
|
||||
return { ...context.headers };
|
||||
}
|
||||
|
||||
has(contextRef: string): boolean {
|
||||
this.#pruneExpired();
|
||||
return this.#contexts.has(contextRef);
|
||||
}
|
||||
|
||||
invalidateLease(leaseRef: string): void {
|
||||
this.#deleteWhere((context) => context.leaseRef === leaseRef);
|
||||
}
|
||||
|
||||
invalidateSession(sessionKey: string): void {
|
||||
this.#deleteWhere((context) => context.sessionKey === sessionKey);
|
||||
}
|
||||
|
||||
invalidateAuthGeneration(sessionKey: string, authGeneration: number): void {
|
||||
this.#deleteWhere(
|
||||
(context) =>
|
||||
context.sessionKey === sessionKey &&
|
||||
context.authGeneration === authGeneration
|
||||
);
|
||||
}
|
||||
|
||||
invalidateCoordinatorEpoch(
|
||||
sessionKey: string,
|
||||
coordinatorEpoch: number
|
||||
): void {
|
||||
this.#deleteWhere(
|
||||
(context) =>
|
||||
context.sessionKey === sessionKey &&
|
||||
context.coordinatorEpoch === coordinatorEpoch
|
||||
);
|
||||
}
|
||||
|
||||
cleanupSender(senderId: number): void {
|
||||
this.#deleteWhere((context) => context.senderId === senderId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#contexts.clear();
|
||||
}
|
||||
|
||||
#deleteWhere(predicate: (context: OpaquePlaybackContext) => boolean): void {
|
||||
for (const [contextRef, context] of this.#contexts) {
|
||||
if (predicate(context)) {
|
||||
this.#contexts.delete(contextRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, value] of playbackContextByOrigin.entries()) {
|
||||
if (now - value.createdAt > CONTEXT_TTL_MS) {
|
||||
playbackContextByOrigin.delete(key);
|
||||
}
|
||||
|
||||
#pruneExpired(): void {
|
||||
const now = this.#now();
|
||||
this.#deleteWhere((context) => context.expiresAt <= now);
|
||||
}
|
||||
}
|
||||
|
||||
export const stalkerPlaybackContextService =
|
||||
new StalkerPlaybackContextService();
|
||||
|
||||
interface LegacyPlaybackContext {
|
||||
readonly createdAt: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const legacyPlaybackContexts = new Map<string, LegacyPlaybackContext>();
|
||||
|
||||
/**
|
||||
* Compatibility bridge for the legacy STALKER_REQUEST path. New full-session
|
||||
* traffic must use {@link StalkerPlaybackContextService} and an opaque ref.
|
||||
*/
|
||||
export function rememberStalkerPlaybackContext(input: {
|
||||
streamUrl: string;
|
||||
portalUrl: string;
|
||||
macAddress: string;
|
||||
serialNumber?: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const streamKey = normalizeStreamUrl(input.streamUrl);
|
||||
if (!streamKey || !input.macAddress) return;
|
||||
}): void {
|
||||
let streamUrl: string;
|
||||
try {
|
||||
streamUrl = normalizeExactStreamUrl(input.streamUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!input.macAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialNumber = normalizeStalkerSerialNumber(input.serialNumber);
|
||||
const cookieParts = [
|
||||
@@ -77,33 +231,26 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
'stb_lang=en_US@rg=dezzzz',
|
||||
'timezone=Europe/Berlin',
|
||||
];
|
||||
|
||||
if (serialNumber) {
|
||||
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
|
||||
}
|
||||
|
||||
const streamOrigin = getOrigin(input.streamUrl) || undefined;
|
||||
|
||||
const streamOrigin = new URL(streamUrl).origin;
|
||||
let portalOrigin: string | undefined;
|
||||
try {
|
||||
portalOrigin = new URL(input.portalUrl).origin;
|
||||
} catch {
|
||||
portalOrigin = undefined;
|
||||
}
|
||||
|
||||
const crossOriginStream =
|
||||
Boolean(streamOrigin) &&
|
||||
Boolean(portalOrigin) &&
|
||||
streamOrigin !== portalOrigin;
|
||||
|
||||
portalOrigin !== undefined && streamOrigin !== portalOrigin;
|
||||
const headers: Record<string, string> = crossOriginStream
|
||||
? {
|
||||
// Align with known working clients for direct tokenized stream URLs.
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
Accept: '*/*',
|
||||
Range: STALKER_STREAM_RANGE_HEADER,
|
||||
'Icy-MetaData': '1',
|
||||
Connection: 'keep-alive',
|
||||
'Icy-MetaData': '1',
|
||||
Range: STALKER_STREAM_RANGE_HEADER,
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
}
|
||||
: {
|
||||
Cookie: cookieParts.join('; '),
|
||||
@@ -115,42 +262,119 @@ export function rememberStalkerPlaybackContext(input: {
|
||||
headers['Origin'] = portalOrigin;
|
||||
headers['Referer'] = portalOrigin;
|
||||
}
|
||||
|
||||
if (!crossOriginStream && serialNumber) {
|
||||
headers['SN'] = serialNumber;
|
||||
}
|
||||
|
||||
if (!crossOriginStream && input.token) {
|
||||
headers['Authorization'] = `Bearer ${input.token}`;
|
||||
}
|
||||
|
||||
playbackContextByStream.set(streamKey, {
|
||||
headers,
|
||||
legacyPlaybackContexts.set(streamUrl, {
|
||||
createdAt: Date.now(),
|
||||
headers: Object.freeze({ ...headers }),
|
||||
});
|
||||
if (streamOrigin) {
|
||||
playbackContextByOrigin.set(streamOrigin, {
|
||||
headers,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
removeExpiredContextEntries();
|
||||
pruneLegacyContexts();
|
||||
}
|
||||
|
||||
export function getStalkerPlaybackContextHeaders(
|
||||
streamUrl: string
|
||||
): Record<string, string> | null {
|
||||
removeExpiredContextEntries();
|
||||
const streamKey = normalizeStreamUrl(streamUrl);
|
||||
if (streamKey) {
|
||||
const entry = playbackContextByStream.get(streamKey);
|
||||
if (entry) {
|
||||
return { ...entry.headers };
|
||||
pruneLegacyContexts();
|
||||
let key: string;
|
||||
try {
|
||||
key = normalizeExactStreamUrl(streamUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const context = legacyPlaybackContexts.get(key);
|
||||
return context ? { ...context.headers } : null;
|
||||
}
|
||||
|
||||
function normalizeExactStreamUrl(streamUrl: string): string {
|
||||
const trimmed = String(streamUrl ?? '').trim();
|
||||
if (!trimmed || trimmed.length > MAX_URL_LENGTH) {
|
||||
throw invalidContext();
|
||||
}
|
||||
const separator = trimmed.indexOf(' ');
|
||||
const unwrapped =
|
||||
separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed;
|
||||
const url = new URL(unwrapped);
|
||||
if (
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
||||
url.username !== '' ||
|
||||
url.password !== ''
|
||||
) {
|
||||
throw invalidContext();
|
||||
}
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function copyHeaders(
|
||||
headers: Readonly<Record<string, string>>
|
||||
): Readonly<Record<string, string>> {
|
||||
const entries = Object.entries(headers);
|
||||
if (entries.length > MAX_HEADERS) {
|
||||
throw invalidContext();
|
||||
}
|
||||
const result: Record<string, string> = {};
|
||||
for (const [name, value] of entries) {
|
||||
if (
|
||||
!isHeaderName(name) ||
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.length > MAX_HEADER_VALUE_LENGTH ||
|
||||
/[\r\n\0]/u.test(value)
|
||||
) {
|
||||
throw invalidContext();
|
||||
}
|
||||
result[name] = value;
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
function isHeaderName(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
value.length <= MAX_HEADER_NAME_LENGTH &&
|
||||
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function assertSafeInteger(value: number): void {
|
||||
if (!isSafeInteger(value)) {
|
||||
throw invalidContext();
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeInteger(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
function assertBinding(value: string, maximum = MAX_BINDING_LENGTH): void {
|
||||
if (!isBinding(value, maximum)) {
|
||||
throw invalidContext();
|
||||
}
|
||||
}
|
||||
|
||||
function isBinding(value: string, maximum: number): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!/[\0\r\n]/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function pruneLegacyContexts(): void {
|
||||
const now = Date.now();
|
||||
for (const [streamUrl, context] of legacyPlaybackContexts) {
|
||||
if (now - context.createdAt > PLAYBACK_CONTEXT_TTL_MS) {
|
||||
legacyPlaybackContexts.delete(streamUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const origin = getOrigin(streamUrl);
|
||||
if (!origin) return null;
|
||||
const originEntry = playbackContextByOrigin.get(origin);
|
||||
return originEntry ? { ...originEntry.headers } : null;
|
||||
}
|
||||
|
||||
function invalidContext(): Error {
|
||||
return new Error('invalid-playback-context');
|
||||
}
|
||||
@@ -311,4 +311,100 @@ describe('StalkerAuthSession', () => {
|
||||
expect(JSON.stringify(outcome)).not.toContain('token-secret');
|
||||
expect(JSON.stringify(outcome)).not.toContain('private-password');
|
||||
});
|
||||
|
||||
it('prepares exact same-origin playback headers from the private token and live cookie jar', async () => {
|
||||
const outcome = resolved({
|
||||
kind: 'ready',
|
||||
profile: { login: 'player-user', status: 0 },
|
||||
status: 0,
|
||||
});
|
||||
await outcome.cookieJar.collectResponseCookies(
|
||||
outcome.endpoint,
|
||||
'session=rotated; Path=/; HttpOnly'
|
||||
);
|
||||
const auth = new StalkerAuthSession(outcome, transport, {
|
||||
createHttpSession: () => ({
|
||||
request: async () => {
|
||||
throw new Error('No request expected');
|
||||
},
|
||||
}),
|
||||
});
|
||||
await auth.start();
|
||||
|
||||
const playback = await auth.preparePlayback(
|
||||
'/stream/live.ts?ticket=synthetic'
|
||||
);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://portal.test/stream/live.ts?ticket=synthetic'
|
||||
);
|
||||
expect(playback.headers).toMatchObject({
|
||||
Authorization: 'Bearer token-secret',
|
||||
Cookie: expect.stringContaining('session=rotated'),
|
||||
Referer: 'https://portal.test/c/',
|
||||
'User-Agent': expect.stringContaining('MAG250'),
|
||||
'X-User-Agent': 'Model: MAG250',
|
||||
});
|
||||
expect(playback.headers.Cookie).toContain('mac=00:1A:79:AA:BB:CC');
|
||||
expect(playback.headers).not.toHaveProperty('SN');
|
||||
});
|
||||
|
||||
it('uses a secret-free direct-stream profile for cross-origin playback', async () => {
|
||||
const { auth } = harness(
|
||||
{
|
||||
kind: 'ready',
|
||||
profile: { login: 'player-user', status: 0 },
|
||||
status: 0,
|
||||
},
|
||||
() => {
|
||||
throw new Error('No request expected');
|
||||
}
|
||||
);
|
||||
await auth.start();
|
||||
|
||||
await expect(
|
||||
auth.preparePlayback(
|
||||
'https://media.example.test/live.ts?ticket=synthetic'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Icy-MetaData': '1',
|
||||
Range: 'bytes=0-',
|
||||
'User-Agent': 'KSPlayer',
|
||||
},
|
||||
streamUrl: 'https://media.example.test/live.ts?ticket=synthetic',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects playback preparation before ready and for unsafe URL schemes', async () => {
|
||||
const { auth: pendingAuth } = harness(
|
||||
{
|
||||
kind: 'credentials-required',
|
||||
profile: { status: 2 },
|
||||
status: 2,
|
||||
},
|
||||
() => success({ js: false })
|
||||
);
|
||||
|
||||
await expect(
|
||||
pendingAuth.preparePlayback('https://portal.test/live.ts')
|
||||
).rejects.toThrow('stalker-playback-not-ready');
|
||||
|
||||
const { auth: readyAuth } = harness(
|
||||
{
|
||||
kind: 'ready',
|
||||
profile: { login: 'player-user', status: 0 },
|
||||
status: 0,
|
||||
},
|
||||
() => {
|
||||
throw new Error('No request expected');
|
||||
}
|
||||
);
|
||||
await readyAuth.start();
|
||||
await expect(
|
||||
readyAuth.preparePlayback('file:///tmp/live.ts')
|
||||
).rejects.toThrow('stalker-playback-invalid-url');
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,11 @@ import type {
|
||||
StalkerTransportConfig,
|
||||
StalkerTransportResult,
|
||||
} from './stalker-session.types';
|
||||
import type { StalkerCookieJar } from './stalker-cookie-jar';
|
||||
|
||||
const USERNAME_MAX_BYTES = 512;
|
||||
const PASSWORD_MAX_BYTES = 2048;
|
||||
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
|
||||
|
||||
interface StalkerHttpSessionLike {
|
||||
request(request: StalkerHttpRequest): Promise<StalkerHttpRequestOutcome>;
|
||||
@@ -38,6 +40,11 @@ export interface StalkerAuthSessionDependencies {
|
||||
) => StalkerHttpSessionLike;
|
||||
}
|
||||
|
||||
export interface StalkerPreparedPlayback {
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly streamUrl: string;
|
||||
}
|
||||
|
||||
export interface StalkerAuthCredentials {
|
||||
password: string;
|
||||
username: string;
|
||||
@@ -85,6 +92,7 @@ export type StalkerAuthenticatedRequestOutcome =
|
||||
type AuthState = 'new' | 'awaiting-credentials' | 'ready' | 'failed';
|
||||
|
||||
export class StalkerAuthSession {
|
||||
readonly #cookieJar: StalkerCookieJar;
|
||||
readonly #endpoint: string;
|
||||
readonly #http: StalkerHttpSessionLike;
|
||||
readonly #identity: StalkerIdentityProfile;
|
||||
@@ -101,6 +109,7 @@ export class StalkerAuthSession {
|
||||
transport: StalkerTransportConfig,
|
||||
dependencies: StalkerAuthSessionDependencies = {}
|
||||
) {
|
||||
this.#cookieJar = resolved.cookieJar;
|
||||
this.#endpoint = resolved.endpoint;
|
||||
this.#identity = resolved.identity;
|
||||
this.#profile = resolved.profile.profile;
|
||||
@@ -280,6 +289,36 @@ export class StalkerAuthSession {
|
||||
return this.#acceptedCredentials !== undefined;
|
||||
}
|
||||
|
||||
async preparePlayback(streamUrl: string): Promise<StalkerPreparedPlayback> {
|
||||
if (this.#state !== 'ready') {
|
||||
throw new Error('stalker-playback-not-ready');
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizePlaybackUrl(streamUrl, this.#endpoint);
|
||||
if (new URL(normalizedUrl).origin !== new URL(this.#endpoint).origin) {
|
||||
return {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Connection: 'keep-alive',
|
||||
'Icy-MetaData': '1',
|
||||
Range: 'bytes=0-',
|
||||
'User-Agent': STALKER_STREAM_USER_AGENT,
|
||||
},
|
||||
streamUrl: normalizedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const cookie = await this.#cookieJar.getCookieHeader(normalizedUrl);
|
||||
return {
|
||||
headers: {
|
||||
...this.#identity.headers,
|
||||
Authorization: `Bearer ${this.#token}`,
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
},
|
||||
streamUrl: normalizedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async request(
|
||||
parameters: Readonly<Record<string, string | number>>
|
||||
): Promise<StalkerAuthenticatedRequestOutcome> {
|
||||
@@ -360,6 +399,35 @@ export class StalkerAuthSession {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePlaybackUrl(value: string, endpoint: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.trim() === '' ||
|
||||
value.length > 16 * 1024
|
||||
) {
|
||||
throw new Error('stalker-playback-invalid-url');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const separator = trimmed.indexOf(' ');
|
||||
const command =
|
||||
separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(command, endpoint);
|
||||
} catch {
|
||||
throw new Error('stalker-playback-invalid-url');
|
||||
}
|
||||
if (
|
||||
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
||||
url.username !== '' ||
|
||||
url.password !== ''
|
||||
) {
|
||||
throw new Error('stalker-playback-invalid-url');
|
||||
}
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeResponse(
|
||||
outcome: StalkerHttpRequestOutcome,
|
||||
stage: StalkerSessionStage,
|
||||
|
||||
Reference in new issue
Block a user