mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
1 parent
a6186a46c8
commit
65f81b7110
15 files changed
+361
-109
No files matched your search
@@ -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('&')}`;
|
||||
}
|
||||
Reference in new issue
Block a user