diff --git a/.changes/stalker-cmd-encoding.md b/.changes/stalker-cmd-encoding.md new file mode 100644 index 000000000..d265b0b0f --- /dev/null +++ b/.changes/stalker-cmd-encoding.md @@ -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. diff --git a/apps/electron-backend-e2e/src/providers.e2e.ts b/apps/electron-backend-e2e/src/providers.e2e.ts index 0ff51b833..2fb590abc 100644 --- a/apps/electron-backend-e2e/src/providers.e2e.ts +++ b/apps/electron-backend-e2e/src/providers.e2e.ts @@ -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, diff --git a/apps/electron-backend/src/app/events/stalker-request-url.spec.ts b/apps/electron-backend/src/app/events/stalker-request-url.spec.ts new file mode 100644 index 000000000..bb5292cc9 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-request-url.spec.ts @@ -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); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/stalker-request-url.ts b/apps/electron-backend/src/app/events/stalker-request-url.ts new file mode 100644 index 000000000..1e5226027 --- /dev/null +++ b/apps/electron-backend/src/app/events/stalker-request-url.ts @@ -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 { + 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('&')}`; +} diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index a46a039f5..ccd31ae31 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -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 diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index e3a35d6d2..58bf7f5a4 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -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(), }, }); } diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index c44eaa147..211c62775 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -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'); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 1ccbba9c9..8caaf9658 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -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}` } : {}), diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 19b4d93df..0e58ebfc1 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -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 diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 9c766986c..933a41581 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 ` ` 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 diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index e477acb5a..cabef2bae 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -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) + ); + // 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) + ); + 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' + ); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 57cf4d129..32ac6425d 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -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://'); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 83bbe81d4..597ab8443 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts new file mode 100644 index 000000000..d455d59d9 --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.spec.ts @@ -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 + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts new file mode 100644 index 000000000..9ea6dae0c --- /dev/null +++ b/libs/shared/interfaces/src/lib/stalker-cmd-encoding.util.ts @@ -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; +}