mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
04b2f9b82e
commit
3326f73388
15 files changed
+600
-59
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker portals now receive channel commands exactly as a real set-top box
|
||||
sends them: already-encoded parts of a channel's `cmd` are no longer
|
||||
double-encoded, so strict portals and reseller panels that compare the command
|
||||
literally work again. Playing Stalker channels from Favorites or global
|
||||
collections now also handles portals that answer with relative stream paths.
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addStalkerPortal,
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
defaultStalkerMacAddress,
|
||||
defaultStalkerPortalName,
|
||||
defaultXtreamPortalName,
|
||||
expect,
|
||||
@@ -67,6 +68,61 @@ test.describe('Electron Provider Smoke Tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@stalker @electron delivers cmd to the portal decoded exactly once with query injection blocked', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// Stored cmd with a pre-encoded token (%3A), a literal '+', and a
|
||||
// query-injection attempt (&injected=1#frag).
|
||||
const storedCmd =
|
||||
'ffrt3 http://example.com/ch/123?token=a%3Ab+c&injected=1#frag';
|
||||
|
||||
const response = await app.mainWindow.evaluate(
|
||||
async ({ url, macAddress, cmd }) =>
|
||||
window.electron.stalkerRequest({
|
||||
url,
|
||||
macAddress,
|
||||
params: { action: 'create_link', type: 'itv', cmd },
|
||||
}),
|
||||
{
|
||||
url: `${stalkerMockServer}/portal.php`,
|
||||
macAddress: defaultStalkerMacAddress,
|
||||
cmd: storedCmd,
|
||||
}
|
||||
);
|
||||
|
||||
const js = (
|
||||
response as {
|
||||
js: { cmd_received: string; query_keys_received: string[] };
|
||||
}
|
||||
).js;
|
||||
|
||||
// The portal must see the stored cmd decoded exactly once —
|
||||
// %3A → ':', '+' → space — the same view it gets from a real STB.
|
||||
// The old encodeURIComponent transport double-encoded '%' and
|
||||
// delivered the %3A/+ sequences still encoded.
|
||||
expect(js.cmd_received).toBe(
|
||||
'ffrt3 http://example.com/ch/123?token=a:b c&injected=1#frag'
|
||||
);
|
||||
|
||||
// The '&'/'#' inside cmd stayed inside the cmd value instead of
|
||||
// restructuring the portal query.
|
||||
expect(js.query_keys_received).toEqual([
|
||||
'JsHttpRequest',
|
||||
'action',
|
||||
'cmd',
|
||||
'type',
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron shows refresh overlay immediately from the dashboard Xtream source menu', async ({
|
||||
dataDir,
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { buildStalkerRequestUrl } from './stalker-request-url';
|
||||
|
||||
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,41 @@
|
||||
import { encodeStalkerCmdValue } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Builds the full Stalker portal request URL from an already-validated portal
|
||||
* URL and the prepared request params.
|
||||
*
|
||||
* 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('&')}`;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import { rememberStalkerPlaybackContext } from '../services/stalker-playback-context.service';
|
||||
import { emitPortalDebugEvent } from './portal-debug.events';
|
||||
import { buildStalkerIdentityRequestContext } from './stalker-identity';
|
||||
import { buildStalkerRequestUrl } from './stalker-request-url';
|
||||
import { assertRemoteUrlAllowed } from './url-safety';
|
||||
import { requestWithValidatedRedirects } from '../util/validated-axios';
|
||||
|
||||
@@ -58,42 +59,11 @@ ipcMain.handle(
|
||||
serialNumber,
|
||||
});
|
||||
|
||||
// Build URL with query parameters
|
||||
// Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent)
|
||||
// to preserve forward slashes, matching stalker-to-m3u implementation
|
||||
// SSRF/LFI guard: block non-http(s)/credentialed portal URLs.
|
||||
// Private/LAN targets remain allowed (users run local Stalker servers).
|
||||
await assertRemoteUrlAllowed(url, { allowPrivateNetworks: true });
|
||||
const urlObject = new URL(url);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
Object.entries(requestParams).forEach(([key, value]) => {
|
||||
if (key === 'cmd') {
|
||||
// Encode cmd but preserve forward slashes so the path format
|
||||
// (e.g. /media/12345.mpg) the server expects still survives.
|
||||
// Encoding the remaining characters prevents a malicious portal
|
||||
// from injecting extra query parameters (&, =, #) into the URL.
|
||||
queryParts.push(
|
||||
`${key}=${encodeURIComponent(String(value)).replace(
|
||||
/%2F/gi,
|
||||
'/'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
// Use encodeURIComponent for other params
|
||||
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');
|
||||
}
|
||||
|
||||
// Build final URL with manually constructed query string
|
||||
const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`;
|
||||
const fullUrl = buildStalkerRequestUrl(url, requestParams);
|
||||
|
||||
// Determine timeout based on action type
|
||||
// create_link requests can take longer as server generates stream URL
|
||||
|
||||
@@ -28,6 +28,13 @@ export function handleCreateLink(req: Request, res: Response): void {
|
||||
streamer_id: '1',
|
||||
load: '',
|
||||
error: '',
|
||||
// Mock-only diagnostics (absent from real portal responses): what
|
||||
// this request actually delivered after Express' single query
|
||||
// decode — the same view a PHP portal gets from $_GET. E2E uses
|
||||
// them to pin the cmd wire contract (no double-encoding, no
|
||||
// query-parameter injection through cmd).
|
||||
cmd_received: cmd,
|
||||
query_keys_received: Object.keys(req.query).sort(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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}` } : {}),
|
||||
|
||||
@@ -208,13 +208,21 @@ marker and an `ffrt4://radio/...` command.
|
||||
"cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
|
||||
"streamer_id": "1",
|
||||
"load": "",
|
||||
"error": ""
|
||||
"error": "",
|
||||
"cmd_received": "ffrt4://ch/live/1001/index.m3u8",
|
||||
"query_keys_received": ["JsHttpRequest", "action", "cmd", "type"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the `cmd` field's character sum, so the same item always returns the same stream.
|
||||
|
||||
`cmd_received` and `query_keys_received` are mock-only diagnostics (a real
|
||||
portal does not send them): they echo the request's `cmd` after Express' single
|
||||
query decode — the same view a PHP portal gets from `$_GET` — plus the sorted
|
||||
set of query keys. E2E uses them to pin the client's `cmd` wire contract: no
|
||||
double-encoding, and no query-parameter injection through `cmd`.
|
||||
|
||||
### `get_short_epg`
|
||||
|
||||
```json
|
||||
|
||||
@@ -138,6 +138,51 @@ blank fields are not generated or forwarded to `get_profile`.
|
||||
metadata is independent from M3U playlist EPG metadata and must not depend on
|
||||
M3U-specific EPG fields.
|
||||
|
||||
## Request Transport and `cmd` Encoding
|
||||
|
||||
A real MAG/STB sends `cmd` unencoded: the portal's client JS concatenates raw
|
||||
`key=value` pairs, the browser URL layer escapes only what a URL cannot carry,
|
||||
and PHP's `$_GET` applies exactly one form-urldecode. The portal therefore sees
|
||||
the stored `cmd` decoded **once** — a pre-encoded `%3A` arrives as `:` and a
|
||||
literal `+` arrives as a space. IPTVnator reproduces that reference wire format
|
||||
on both transports with the shared `encodeStalkerCmdValue()`
|
||||
(`libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts`):
|
||||
|
||||
- `%` passes through untouched, so a `cmd` that already contains percent
|
||||
sequences is never double-encoded (the pre-0.23 `encodeURIComponent`
|
||||
transport delivered `%253A` and strict panels no longer matched the string).
|
||||
- Characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
(`/ : ? = + , @ $ [ ]` …), so the emitted bytes survive the axios/`new URL`
|
||||
transport unchanged.
|
||||
- Everything else is percent-encoded. This keeps the injection protection from
|
||||
the 0.22 hardening: `&`, `#` (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, so the portal-visible value is
|
||||
unaffected.
|
||||
|
||||
Consumers of the encoder:
|
||||
|
||||
- Electron: `buildStalkerRequestUrl()`
|
||||
(`apps/electron-backend/src/app/events/stalker-request-url.ts`) assembles the
|
||||
portal query for `STALKER_REQUEST`; `cmd` uses the reference encoding, every
|
||||
other param stays fully `encodeURIComponent`-encoded, and `JsHttpRequest=1-xml`
|
||||
is appended when missing.
|
||||
- PWA: the web-backend `/stalker` proxy appends `cmd` to the portal URL with
|
||||
the same encoder instead of letting axios' serializer turn slashes into
|
||||
`%2F`. The renderer→proxy leg uses `URLSearchParams`, which Express decodes
|
||||
losslessly, so the stored `cmd` string reaches the proxy intact.
|
||||
|
||||
The mock portal's `create_link` response carries mock-only `cmd_received` and
|
||||
`query_keys_received` diagnostics so E2E can pin this contract
|
||||
(`apps/electron-backend-e2e/src/providers.e2e.ts`).
|
||||
|
||||
Response-side `cmd` normalization is also shared: both the Stalker store and
|
||||
the cross-portal collection resolver (`StreamResolverService`) use
|
||||
`normalizeStalkerPlaybackCommand()` / `resolveStalkerPlaybackUrl()` from
|
||||
`libs/portal/stalker/data-access`, which strip the `<solution> ` prefix and
|
||||
resolve relative (`/media/...`) or query-only (`?token=...`) `create_link`
|
||||
replies against the portal base URL.
|
||||
|
||||
## Live TV and Radio
|
||||
|
||||
The Stalker live route and radio route intentionally share
|
||||
|
||||
@@ -696,4 +696,66 @@ describe('StreamResolverService', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative Stalker create_link responses against the portal base', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl:
|
||||
'https://stalker.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
// Portals frequently answer create_link with a solution-prefixed
|
||||
// relative path; the shared normalizer must resolve it instead of
|
||||
// handing the bare path to the player (the old weak normalizer did).
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: 'ffmpeg /media/file_123.mpg' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::88',
|
||||
name: 'Relative Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '88',
|
||||
stalkerCmd: 'ffrt3 http://stalker.example.com/media/999.mpg',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'https://stalker.example.com/stalker_portal/media/file_123.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends query-only Stalker create_link responses to the original cmd URL', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com/portal.php',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: { cmd: '?token=xyz' },
|
||||
});
|
||||
|
||||
const playback = await service.resolvePlayback({
|
||||
uid: 'stalker::stalker-1::89',
|
||||
name: 'Token Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '89',
|
||||
stalkerCmd: 'auto http://cdn.example.com/live/89.m3u8',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(playback.streamUrl).toBe(
|
||||
'http://cdn.example.com/live/89.m3u8?token=xyz'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
normalizeStalkerPlaybackCommand,
|
||||
resolveStalkerPlaybackUrl,
|
||||
StalkerSessionService,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
@@ -323,7 +327,9 @@ export class StreamResolverService {
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? '');
|
||||
const normalizedCmd = normalizeStalkerPlaybackCommand(
|
||||
item.stalkerCmd ?? ''
|
||||
);
|
||||
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
|
||||
return {
|
||||
streamUrl: normalizedCmd,
|
||||
@@ -362,7 +368,14 @@ export class StreamResolverService {
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
return {
|
||||
streamUrl: this.normalizeStalkerCmd(rawCmd),
|
||||
// Shared normalizer from the Stalker store: strips the solution
|
||||
// prefix and resolves relative `/media/...` or `?...` responses
|
||||
// against the portal base instead of returning them verbatim.
|
||||
streamUrl: resolveStalkerPlaybackUrl(
|
||||
portalUrl,
|
||||
item.stalkerCmd ?? '',
|
||||
rawCmd
|
||||
),
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
isLive: item.radio === 'true' ? undefined : true,
|
||||
@@ -1083,26 +1096,6 @@ export class StreamResolverService {
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeStalkerCmd(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export * from './lib/portal-playback.interface';
|
||||
export * from './lib/random-id.util';
|
||||
export * from './lib/security-policy-error.utils';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-cmd-encoding.util';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/store-keys.enum';
|
||||
export * from './lib/stream-format.enum';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { encodeStalkerCmdValue } from './stalker-cmd-encoding.util';
|
||||
|
||||
/**
|
||||
* One application-x-www-form-urlencoded decode, the way PHP's `$_GET` (and
|
||||
* Express' query parser) sees the value: `+` is a space, `%XX` decodes once.
|
||||
*/
|
||||
function portalVisibleValue(encoded: string): string {
|
||||
return decodeURIComponent(encoded.replace(/\+/g, '%20'));
|
||||
}
|
||||
|
||||
describe('encodeStalkerCmdValue', () => {
|
||||
it('keeps a plain solution-prefixed cmd readable (space only)', () => {
|
||||
expect(encodeStalkerCmdValue('ffrt3 http://host/ch/123')).toBe(
|
||||
'ffrt3%20http://host/ch/123'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ?, = and : raw in a tokened cmd', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/ch/123?token=abc')
|
||||
).toBe('auto%20http://host/ch/123?token=abc');
|
||||
});
|
||||
|
||||
it('leaves a bare media path untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/12345.mpg')).toBe(
|
||||
'/media/12345.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-encodes pre-encoded sequences', () => {
|
||||
expect(
|
||||
encodeStalkerCmdValue('auto http://host/s/a%3Ab%20c.m3u8')
|
||||
).toBe('auto%20http://host/s/a%3Ab%20c.m3u8');
|
||||
});
|
||||
|
||||
it('passes a bare, malformed % through untouched', () => {
|
||||
expect(encodeStalkerCmdValue('/media/100%.mpg')).toBe(
|
||||
'/media/100%.mpg'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the query-structure characters &, # and ;', () => {
|
||||
expect(encodeStalkerCmdValue('a&b#c;d')).toBe('a%26b%23c%3Bd');
|
||||
});
|
||||
|
||||
it('keeps + raw so the portal sees a space, like it does for a real STB', () => {
|
||||
const encoded = encodeStalkerCmdValue('auto http://host/s/a+b.ts');
|
||||
expect(encoded).toBe('auto%20http://host/s/a+b.ts');
|
||||
expect(portalVisibleValue(encoded)).toBe('auto http://host/s/a b.ts');
|
||||
});
|
||||
|
||||
it('keeps IPv6 literals byte-identical', () => {
|
||||
expect(encodeStalkerCmdValue('ffmpeg http://[::1]:8080/ch/1')).toBe(
|
||||
'ffmpeg%20http://[::1]:8080/ch/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes quotes, backslash and control characters', () => {
|
||||
expect(encodeStalkerCmdValue(`a"b'c\\d\ne`)).toBe(
|
||||
'a%22b%27c%5Cd%0Ae'
|
||||
);
|
||||
});
|
||||
|
||||
it('percent-encodes non-ASCII as UTF-8', () => {
|
||||
expect(encodeStalkerCmdValue('auto http://host/канал/1')).toBe(
|
||||
'auto%20http://host/%D0%BA%D0%B0%D0%BD%D0%B0%D0%BB/1'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives WHATWG URL re-parsing byte-identically', () => {
|
||||
const corpus = [
|
||||
'ffrt3 http://host/ch/123',
|
||||
'auto http://host/ch/123?token=abc',
|
||||
'/media/12345.mpg',
|
||||
'auto http://host/s/a%3Ab%20c.m3u8',
|
||||
"a&b#c;d'e\"f",
|
||||
'ffmpeg http://[::1]:8080/ch/1',
|
||||
'auto http://host/канал/1',
|
||||
];
|
||||
for (const cmd of corpus) {
|
||||
const query = `cmd=${encodeStalkerCmdValue(cmd)}`;
|
||||
expect(new URL(`http://portal/load.php?${query}`).search).toBe(
|
||||
`?${query}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('is value-preserving after one server-side decode for every escaped character', () => {
|
||||
const original = `ffrt http://h/s?a=1&b=2#f;g "q" 'x' я`;
|
||||
expect(portalVisibleValue(encodeStalkerCmdValue(original))).toBe(
|
||||
original
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire encoding for the Stalker `cmd` query parameter.
|
||||
*
|
||||
* A real MAG/STB sends `cmd` unencoded: the portal's own client JS
|
||||
* concatenates raw `key=value` pairs, WebKit's URL layer escapes only the
|
||||
* characters a URL cannot carry (space, quotes, non-ASCII), and PHP's `$_GET`
|
||||
* then applies exactly one form-urldecode. The portal therefore sees the
|
||||
* stored cmd decoded **once** — pre-encoded sequences such as `%3A` arrive as
|
||||
* `:`, and a literal `+` arrives as a space.
|
||||
*
|
||||
* `encodeURIComponent` (the previous behavior) broke that contract for any
|
||||
* cmd already containing `%`: `%3A` went out as `%253A` and reached the
|
||||
* portal still encoded, so strict panels and the stock
|
||||
* `preg_match`-based `create_link` handlers saw a different string than a
|
||||
* real STB would send.
|
||||
*
|
||||
* This encoder reproduces the reference wire bytes instead:
|
||||
*
|
||||
* - `%` passes through untouched (never double-encoded);
|
||||
* - characters the WHATWG URL serializer keeps raw in a query stay raw
|
||||
* (`/ : ? = + , @ $ [ ] ! * ( ) ~ - _ .`), so the bytes we emit survive
|
||||
* `new URL(...)` unchanged;
|
||||
* - everything else is percent-encoded. That covers the characters that
|
||||
* would restructure our request (`&`, `#`, and `;` for PHP setups with a
|
||||
* `;` argument separator) — a malicious portal cannot smuggle extra query
|
||||
* parameters through cmd — plus characters a URL cannot carry raw (space,
|
||||
* quotes, control characters, non-ASCII). All of them decode back to the
|
||||
* original byte on the server, so the portal-visible value is unaffected.
|
||||
*/
|
||||
|
||||
const SAFE_CMD_CHAR = /^[A-Za-z0-9\-_.~!*()/:?@$,+=[\]%]$/;
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Percent-encode every UTF-8 byte of a character. Deliberately not
|
||||
* `encodeURIComponent`, whose unreserved set (e.g. `'`) overlaps characters
|
||||
* this encoder must escape because `new URL(...)` would re-encode them and
|
||||
* change the wire bytes behind our back.
|
||||
*/
|
||||
function percentEncodeChar(char: string): string {
|
||||
let encoded = '';
|
||||
for (const byte of utf8Encoder.encode(char)) {
|
||||
encoded += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function encodeStalkerCmdValue(value: string): string {
|
||||
let encoded = '';
|
||||
for (const char of value) {
|
||||
encoded += SAFE_CMD_CHAR.test(char) ? char : percentEncodeChar(char);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
Reference in new issue
Block a user