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 211c62775..b1448144f 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -440,6 +440,44 @@ https://stream.example/live.m3u8`); ); }); + it('keeps cmd in the query when the registered portal URL carries a fragment or bare ?', async () => { + const httpClient = new StubHttpClient(); + httpClient.queueResponse({ js: { cmd: 'http://cdn/a.m3u8' } }); + httpClient.queueResponse({ js: { cmd: 'http://cdn/b.m3u8' } }); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + // A fragment on the registered URL must not swallow the + // appended cmd (fragments are never sent to the portal). + const fragmentTarget = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php#legacy' + ); + await fetch( + `${baseUrl}/stalker?targetId=${fragmentTarget}&action=create_link&cmd=${encodeURIComponent('/media/1.mpg')}` + ); + + // A trailing bare '?' must not produce '??cmd='. + const bareQueryTarget = await registerProviderTarget( + baseUrl, + 'http://stalker.example/load.php?' + ); + await fetch( + `${baseUrl}/stalker?targetId=${bareQueryTarget}&action=create_link&cmd=${encodeURIComponent('/media/2.mpg')}` + ); + + expect(httpClient.requests.map((request) => request.url)).toEqual([ + 'http://stalker.example/portal.php?cmd=/media/1.mpg', + 'http://stalker.example/load.php?cmd=/media/2.mpg', + ]); + } + ); + }); + 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 8caaf9658..7099c9314 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -233,10 +233,21 @@ export function createWebBackendApp( // 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. + // The fragment is dropped first: a registered URL carrying `#...` + // would otherwise swallow the appended cmd, and a trailing bare + // `?` must not become `??cmd=`. const { cmd, ...proxyParams } = getProxyParams(req, ['targetId']); - const requestUrl = cmd - ? `${url.href}${url.search ? '&' : '?'}cmd=${encodeStalkerCmdValue(cmd)}` - : url.href; + const portalUrl = new URL(url.href); + portalUrl.hash = ''; + let requestUrl = portalUrl.href; + if (cmd) { + const separator = requestUrl.endsWith('?') + ? '' + : portalUrl.search + ? '&' + : '?'; + requestUrl = `${requestUrl}${separator}cmd=${encodeStalkerCmdValue(cmd)}`; + } // Provider URLs are validated by /provider-targets before they enter the registry. // codeql[js/request-forgery]