fix(pwa): bring the Stalker transport to parity with Electron (#1348)

* fix(pwa): bring the Stalker transport to parity with Electron

The self-hosted PWA's /stalker proxy now derives its portal requests from
the same shared identity and URL builders as the Electron main process:
MAG User-Agent/X-User-Agent, full STB cookie (mac + stb_lang + timezone +
serial-derived __cfduid), SN header, JsHttpRequest=1-xml defaulting, and
the sn-only-on-get_profile rule. macAddress/token/serialNumber are control
params consumed into headers and never echoed into the portal's query
string (handshake keeps its candidate token — protocol content). The
stalker-mock-server /stalker route mirrors the new contract through the
same shared builder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(stalker): forward the full identity header set in the mock /stalker mirror

Greptile review: the synthetic portal request kept only the cookie and
Authorization from the generated identity, so mock handlers could never
validate the SN/MAG-UA/Accept/Language/Connection headers the real proxy
sends. Forward the complete set, lowercased the way Express normalizes
incoming headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-02 14:51:36 +02:00
1 parent a6186a46c8
commit 65f81b7110
15 files changed
+361 -109

No files matched your search

+2
View File
@@ -68,6 +68,8 @@ export * from './lib/xtream-vod-stream.interface';
// Stalker interfaces
export * from './lib/stalker-item.normalizer';
export * from './lib/stalker-identity.utils';
export * from './lib/stalker-request-identity.util';
export * from './lib/stalker-request-url.util';
export * from './lib/stalker-portal-item.interface';
export * from './lib/stalker-stream-profile.util';
export * from './lib/stalker-serial-details.interface';
@@ -0,0 +1,93 @@
import { LEGACY_DEFAULT_STALKER_SERIAL } from './stalker-identity.utils';
import { buildStalkerIdentityRequestContext } from './stalker-request-identity.util';
describe('buildStalkerIdentityRequestContext', () => {
const macAddress = '00:1A:79:AA:BB:CC';
it('does not add SN header or force get_profile SN fields when serial is absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'stb',
action: 'get_profile',
sn: 'STALE-SN',
metrics: JSON.stringify({
mac: macAddress,
sn: 'STALE-SN',
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams['metrics']))
).not.toHaveProperty('sn');
});
it('preserves a provided serial exactly in headers, params, and metrics', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: 'CustomSn123',
params: {
type: 'stb',
action: 'get_profile',
metrics: JSON.stringify({
mac: macAddress,
}),
},
});
expect(context.effectiveSerialNumber).toBe('CustomSn123');
expect(context.headers['SN']).toBe('CustomSn123');
expect(context.cookieString).toContain('__cfduid=');
expect(
context.cookieString.match(/__cfduid=([^;]+)/)?.[1]
).toHaveLength(32);
expect(context.requestParams['sn']).toBe('CustomSn123');
expect(JSON.parse(String(context.requestParams['metrics']))).toEqual(
expect.objectContaining({
sn: 'CustomSn123',
})
);
});
it('treats the legacy default serial as absent', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
serialNumber: LEGACY_DEFAULT_STALKER_SERIAL,
params: {
type: 'stb',
action: 'get_profile',
sn: LEGACY_DEFAULT_STALKER_SERIAL,
metrics: JSON.stringify({
mac: macAddress,
sn: LEGACY_DEFAULT_STALKER_SERIAL,
}),
},
});
expect(context.effectiveSerialNumber).toBeUndefined();
expect(context.headers).not.toHaveProperty('SN');
expect(context.cookieString).not.toContain('__cfduid=');
expect(context.requestParams).not.toHaveProperty('sn');
expect(
JSON.parse(String(context.requestParams['metrics']))
).not.toHaveProperty('sn');
});
it('removes stale SN params from non-profile requests', () => {
const context = buildStalkerIdentityRequestContext({
macAddress,
params: {
type: 'itv',
action: 'get_ordered_list',
sn: 'STALE-SN',
},
});
expect(context.requestParams).not.toHaveProperty('sn');
});
});
@@ -0,0 +1,130 @@
import {
buildStalkerSerialCfduid,
normalizeStalkerSerialNumber,
} from './stalker-identity.utils';
/**
* User-Agent of a MAG250 set-top box (matches the stalker-to-m3u reference
* implementation). Sent as both `User-Agent` and `X-User-Agent` on every
* portal API request — some panels reject requests without it.
*/
export const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
export interface StalkerIdentityRequestInput {
macAddress: string;
params: Record<string, string | number>;
token?: string;
serialNumber?: string;
}
export interface StalkerIdentityRequestContext {
requestParams: Record<string, string | number>;
headers: Record<string, string>;
cookieString: string;
effectiveSerialNumber?: string;
}
/**
* Builds the portal-facing identity of a Stalker request: the cookie a real
* STB sends (`mac` + `stb_lang` + `timezone`, plus a serial-derived
* `__cfduid`), the MAG User-Agent pair, the `SN`/`Authorization` headers, and
* the serial-number parameter rules (`sn` travels only on `get_profile`).
*
* This is the single source of truth for BOTH transports — the Electron main
* process and the self-hosted PWA's web-backend `/stalker` proxy — so the two
* cannot drift apart again. The stock server reads the timezone cookie with
* `new DateTimeZone()`, so it must always be a valid PHP timezone.
*/
export function buildStalkerIdentityRequestContext({
macAddress,
params,
token,
serialNumber,
}: StalkerIdentityRequestInput): StalkerIdentityRequestContext {
const effectiveSerialNumber = normalizeStalkerSerialNumber(serialNumber);
const requestParams = buildRequestParams(params, effectiveSerialNumber);
const cookieString = buildCookieString(macAddress, effectiveSerialNumber);
const headers: Record<string, string> = {
Cookie: cookieString,
'User-Agent': STALKER_MAG_USER_AGENT,
'X-User-Agent': STALKER_MAG_USER_AGENT,
Accept: '*/*',
Connection: 'keep-alive',
'Accept-Language': 'en-US,en;q=0.9',
};
if (effectiveSerialNumber) {
headers['SN'] = effectiveSerialNumber;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
requestParams,
headers,
cookieString,
...(effectiveSerialNumber ? { effectiveSerialNumber } : {}),
};
}
function buildRequestParams(
params: Record<string, string | number>,
effectiveSerialNumber: string | undefined
): Record<string, string | number> {
const requestParams = { ...params };
const isProfileRequest =
requestParams['type'] === 'stb' &&
requestParams['action'] === 'get_profile';
if (!isProfileRequest) {
delete requestParams['sn'];
return requestParams;
}
if (effectiveSerialNumber) {
requestParams['sn'] = effectiveSerialNumber;
} else {
delete requestParams['sn'];
}
if (typeof requestParams['metrics'] === 'string') {
try {
const parsedMetrics = JSON.parse(requestParams['metrics']);
const nextMetrics = { ...(parsedMetrics ?? {}) };
if (effectiveSerialNumber) {
nextMetrics.sn = effectiveSerialNumber;
} else {
delete nextMetrics.sn;
}
requestParams['metrics'] = JSON.stringify(nextMetrics);
} catch {
// Keep original metrics payload when malformed.
}
}
return requestParams;
}
function buildCookieString(
macAddress: string,
effectiveSerialNumber: string | undefined
): string {
const cookieParts = [
`mac=${macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (effectiveSerialNumber) {
cookieParts.push(
`__cfduid=${buildStalkerSerialCfduid(effectiveSerialNumber)}`
);
}
return cookieParts.join('; ');
}
@@ -0,0 +1,136 @@
import { buildStalkerRequestUrl } from './stalker-request-url.util';
const PORTAL = 'http://portal.example/stalker_portal/server/load.php';
/** Decode a query value the way PHP's `$_GET` would (one form-urldecode). */
function portalVisibleValue(encoded: string): string {
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
}
function cmdWireValue(fullUrl: string): string {
const match = /[?&]cmd=([^&]*)/.exec(fullUrl);
if (!match) {
throw new Error(`no cmd param in ${fullUrl}`);
}
return match[1];
}
describe('buildStalkerRequestUrl', () => {
it('builds the reference wire format for a typical create_link request', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
type: 'itv',
action: 'create_link',
cmd: 'ffrt3 http://host/ch/123',
});
expect(fullUrl).toBe(
'http://portal.example/stalker_portal/server/load.php' +
'?type=itv&action=create_link' +
'&cmd=ffrt3%20http://host/ch/123&JsHttpRequest=1-xml'
);
});
it.each([
['ffrt3 http://host/ch/123', 'ffrt3%20http://host/ch/123'],
[
'auto http://host/ch/123?token=abc',
'auto%20http://host/ch/123?token=abc',
],
['/media/12345.mpg', '/media/12345.mpg'],
[
'auto http://host/s/a%3Ab%20c.m3u8',
'auto%20http://host/s/a%3Ab%20c.m3u8',
],
])('sends cmd %s as %s', (cmd, expectedWireValue) => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd,
});
expect(cmdWireValue(fullUrl)).toBe(expectedWireValue);
});
it('does not double-encode a cmd that already contains percent sequences', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd: 'auto http://host/s/a%3Ab.m3u8?sig=x%2Fy',
});
expect(fullUrl).not.toContain('%25');
// After the portal's single decode, pre-encoded sequences resolve —
// exactly what it would receive from a real STB.
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(
'auto http://host/s/a:b.m3u8?sig=x/y'
);
});
it('blocks query-parameter injection through cmd without losing data', () => {
const maliciousCmd =
'http://host/ch/1?x=1&action=do_evil&mac=00:00:00:00:00:00#frag';
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
type: 'itv',
cmd: maliciousCmd,
});
const params = new URL(fullUrl).searchParams;
expect(params.getAll('action')).toEqual(['create_link']);
expect(params.get('mac')).toBeNull();
expect(params.get('x')).toBeNull();
expect(fullUrl).not.toContain('#');
// The dangerous characters are escaped, not stripped: the portal
// still receives the full original cmd string after one decode.
expect(portalVisibleValue(cmdWireValue(fullUrl))).toBe(maliciousCmd);
});
it('keeps full encoding for non-cmd params', () => {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'get_profile',
metrics: '{"mac":"00:1A:79:AA:BB:CC"}',
});
expect(fullUrl).toContain(
'metrics=%7B%22mac%22%3A%2200%3A1A%3A79%3AAA%3ABB%3ACC%22%7D'
);
});
it('appends JsHttpRequest only when missing', () => {
const withoutIt = buildStalkerRequestUrl(PORTAL, { action: 'x' });
expect(withoutIt.match(/JsHttpRequest/g)).toHaveLength(1);
const withIt = buildStalkerRequestUrl(PORTAL, {
action: 'x',
JsHttpRequest: '1-xml',
});
expect(withIt.match(/JsHttpRequest/g)).toHaveLength(1);
});
it('drops any query string carried by the portal URL itself', () => {
const fullUrl = buildStalkerRequestUrl(
'http://portal.example/portal.php?stale=1',
{ action: 'handshake' }
);
expect(fullUrl).toBe(
'http://portal.example/portal.php?action=handshake&JsHttpRequest=1-xml'
);
});
it('emits URLs whose bytes survive WHATWG re-parsing (the axios transport)', () => {
const cmds = [
'ffrt3 http://host/ch/123',
'auto http://host/ch/123?token=a%3Ab c',
'/media/12345.mpg',
'x&y=z#w;v',
'auto http://host/канал/1',
];
for (const cmd of cmds) {
const fullUrl = buildStalkerRequestUrl(PORTAL, {
action: 'create_link',
cmd,
});
expect(new URL(fullUrl).toString()).toBe(fullUrl);
}
});
});
@@ -0,0 +1,43 @@
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
/**
* Builds the full Stalker portal request URL from an already-validated portal
* URL and the prepared request params. Shared by the Electron main process
* and the web-backend `/stalker` proxy so both transports emit the exact same
* wire format.
*
* The query string is assembled manually because the two parameter classes
* need different encodings:
*
* - `cmd` uses the minimal reference encoding (`encodeStalkerCmdValue`): a
* real MAG sends cmd unencoded and the portal decodes the query exactly
* once, so pre-encoded sequences (`%3A`) must pass through untouched while
* `&`/`#`/`;` are still escaped so a malicious portal cannot append query
* parameters through cmd.
* - every other param is fully `encodeURIComponent`-encoded.
*
* Any query string on the portal URL itself is intentionally dropped (the
* request params are the complete query), matching long-standing behavior.
*/
export function buildStalkerRequestUrl(
url: string,
requestParams: Record<string, string | number>
): string {
const urlObject = new URL(url);
const queryParts: string[] = [];
Object.entries(requestParams).forEach(([key, value]) => {
if (key === 'cmd') {
queryParts.push(`${key}=${encodeStalkerCmdValue(String(value))}`);
} else {
queryParts.push(`${key}=${encodeURIComponent(String(value))}`);
}
});
// Always add JsHttpRequest parameter if not present (required by Stalker API)
if (!requestParams['JsHttpRequest']) {
queryParts.push('JsHttpRequest=1-xml');
}
return `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
}