fix(stalker): send cmd in the reference MAG wire format

A real MAG sends cmd unencoded and the portal decodes its query exactly
once, so a cmd that already contains percent sequences (%3A tokens,
pre-encoded path segments) must pass through untouched. The previous
encodeURIComponent transport (2c032cd3c, 0.22) double-encoded such cmds
(%3A -> %253A): strict portals and reseller panels that compare cmd
literally, and stock create_link handlers matching the decoded value,
saw a different string than a real STB sends.

The new shared encodeStalkerCmdValue() reproduces the reference wire
bytes: % passes through verbatim, characters the WHATWG URL serializer
keeps raw in a query stay raw (so the bytes survive the axios/new URL
transport unchanged), and everything else is percent-encoded. That
preserves the 0.22 injection protection - &, # (and ; for PHP setups
with a ; argument separator) inside cmd cannot append or truncate query
parameters; they decode back to the original byte server-side.

Both transports now share the format: the Electron query builder is
extracted to buildStalkerRequestUrl() and the web-backend /stalker
proxy appends cmd to the portal URL itself instead of letting axios
turn slashes into %2F (the opposite divergence).

Also unifies the two divergent response-side cmd normalizers: the
cross-portal collection resolver now uses the Stalker store's
normalizeStalkerPlaybackCommand/resolveStalkerPlaybackUrl, so playing
from Favorites/global collections resolves relative (/media/...) and
query-only (?token=...) create_link replies against the portal base
instead of handing the player a bare relative path.

The mock portal's create_link response gains mock-only cmd_received/
query_keys_received diagnostics; a new Electron e2e pins the contract
end-to-end (single decode, injection blocked). Unit corpus tests cover
the encoder, the Electron builder, the web-backend proxy, and the
resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-01 22:33:47 +02:00
1 parent 04b2f9b82e
commit 3326f73388
15 files changed
+600 -59

No files matched your search

@@ -390,6 +390,56 @@ https://stream.example/live.m3u8`);
);
});
it('forwards Stalker cmd in the reference wire format instead of axios encoding', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ js: { cmd: 'http://cdn/stream.m3u8' } });
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://stalker.example/portal.php'
);
// The PWA renderer sends fully URLSearchParams-encoded values;
// Express decodes them back to the stored cmd string.
const query = new URLSearchParams({
targetId,
macAddress: '00:1A:79:00:00:01',
action: 'create_link',
type: 'itv',
cmd: 'ffrt3 http://host/ch/123?token=a%3Ab c&x=1',
});
const response = await fetch(
`${baseUrl}/stalker?${query.toString()}`
);
await response.json();
// Slashes stay raw and pre-encoded sequences pass through
// untouched (no %25 double-encoding); '&' inside cmd cannot
// append query parameters. cmd is no longer in axios params.
expect(httpClient.requests).toEqual([
{
headers: {
Cookie: 'mac=00:1A:79:00:00:01',
},
params: {
action: 'create_link',
macAddress: '00:1A:79:00:00:01',
type: 'itv',
},
url:
'http://stalker.example/portal.php' +
'?cmd=ffrt3%20http://host/ch/123?token=a%3Ab%20c%26x=1',
},
]);
}
);
});
it('normalizes provider errors for portal proxy calls', async () => {
const httpClient = new StubHttpClient();
httpClient.queueFailure(403, 'Forbidden');
+16 -3
View File
@@ -7,7 +7,10 @@ import zlib from 'node:zlib';
import axios from 'axios';
import epgParser from 'epg-parser';
import parser from 'iptv-playlist-parser';
import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces';
import {
encodeStalkerCmdValue,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
export interface WebBackendHttpGetOptions {
@@ -225,10 +228,20 @@ export function createWebBackendApp(
}
try {
// `cmd` must reach the portal in the reference wire format (raw
// slashes, pre-encoded sequences untouched) — axios' default
// serializer would fully percent-encode it, diverging from what a
// real STB (and the Electron transport) sends. Append it to the
// URL with the shared encoder and let axios serialize the rest.
const { cmd, ...proxyParams } = getProxyParams(req, ['targetId']);
const requestUrl = cmd
? `${url.href}${url.search ? '&' : '?'}cmd=${encodeStalkerCmdValue(cmd)}`
: url.href;
// Provider URLs are validated by /provider-targets before they enter the registry.
// codeql[js/request-forgery]
const response = await httpClient.get(url.href, {
params: getProxyParams(req, ['targetId']),
const response = await httpClient.get(requestUrl, {
params: proxyParams,
headers: {
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),