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 854e374f4..6f31aadcc 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -63,6 +63,21 @@ class StubHttpClient implements WebBackendHttpClient { const resolvePublicHost = async () => ['93.184.216.34']; +async function registerProviderTarget( + baseUrl: string, + url: string +): Promise { + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ url }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); + const body = (await response.json()) as { targetId: string }; + return body.targetId; +} + async function withServer( app: ReturnType, callback: (baseUrl: string) => Promise @@ -126,8 +141,12 @@ https://stream.example/news.m3u8`); resolveHostname: resolvePublicHost, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/list.m3u' + ); const response = await fetch( - `${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`, + `${baseUrl}/parse?targetId=${targetId}`, { headers: { Origin: 'http://localhost:4200' } } ); const body = (await response.json()) as { @@ -135,9 +154,9 @@ https://stream.example/news.m3u8`); }; expect(response.status).toBe(200); - expect(response.headers.get('access-control-allow-origin')).toBe( - 'http://localhost:4200' - ); + expect( + response.headers.get('access-control-allow-origin') + ).toBe('http://localhost:4200'); expect(body).toMatchObject({ _id: 'fixed-id', autoRefresh: false, @@ -167,6 +186,29 @@ https://stream.example/news.m3u8`); ); }); + it('allows browser preflight checks for provider target registration', async () => { + await withServer( + createWebBackendApp({ + clientOrigins: ['http://localhost:4200'], + }), + async (baseUrl) => { + const response = await fetch(`${baseUrl}/provider-targets`, { + headers: { + 'Access-Control-Request-Headers': 'content-type', + 'Access-Control-Request-Method': 'POST', + Origin: 'http://localhost:4200', + }, + method: 'OPTIONS', + }); + + expect(response.status).toBe(200); + expect( + response.headers.get('access-control-allow-origin') + ).toBe('http://localhost:4200'); + } + ); + }); + it('proxies Xtream requests through the provider player API endpoint', async () => { const httpClient = new StubHttpClient(); httpClient.queueResponse({ user_info: { username: 'demo' } }); @@ -177,8 +219,12 @@ https://stream.example/news.m3u8`); resolveHostname: resolvePublicHost, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); const response = await fetch( - `${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&username=demo&password=secret&action=get_account_info` + `${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info` ); await expect(response.json()).resolves.toEqual({ @@ -210,8 +256,12 @@ https://stream.example/news.m3u8`); resolveHostname: resolvePublicHost, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://stalker.example/portal.php' + ); const response = await fetch( - `${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod` + `${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod` ); await expect(response.json()).resolves.toEqual({ @@ -247,8 +297,12 @@ https://stream.example/news.m3u8`); resolveHostname: resolvePublicHost, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); const response = await fetch( - `${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info` + `${baseUrl}/xtream?targetId=${targetId}&action=get_account_info` ); await expect(response.json()).resolves.toEqual({ @@ -269,8 +323,12 @@ https://stream.example/news.m3u8`); resolveHostname: resolvePublicHost, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'https://provider.example/list.m3u' + ); const response = await fetch( - `${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}` + `${baseUrl}/parse?targetId=${targetId}` ); expect(response.status).toBe(502); @@ -291,9 +349,13 @@ https://stream.example/news.m3u8`); await withServer( createWebBackendApp({ httpClient }), async (baseUrl) => { - const response = await fetch( - `${baseUrl}/parse?url=${encodeURIComponent('file:///etc/passwd')}` - ); + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ url: 'file:///etc/passwd' }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ @@ -311,9 +373,15 @@ https://stream.example/news.m3u8`); await withServer( createWebBackendApp({ httpClient }), async (baseUrl) => { - const response = await fetch( - `${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&action=get_account_info` - ); + const response = await fetch(`${baseUrl}/provider-targets`, { + body: JSON.stringify({ + url: 'http://127.0.0.1:3211', + }), + headers: { + 'content-type': 'application/json', + }, + method: 'POST', + }); expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ @@ -336,8 +404,12 @@ https://stream.example/news.m3u8`); httpClient, }), async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://127.0.0.1:3211' + ); const response = await fetch( - `${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&username=demo&password=secret&action=get_account_info` + `${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info` ); await expect(response.json()).resolves.toEqual({ @@ -350,4 +422,36 @@ https://stream.example/news.m3u8`); } ); }); + + it('requires portal proxy callers to use registered provider targets', async () => { + const httpClient = new StubHttpClient(); + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: resolvePublicHost, + }), + async (baseUrl) => { + const missingTargetResponse = await fetch( + `${baseUrl}/xtream?action=get_account_info` + ); + const unknownTargetResponse = await fetch( + `${baseUrl}/xtream?targetId=missing&action=get_account_info` + ); + + expect(missingTargetResponse.status).toBe(400); + await expect(missingTargetResponse.json()).resolves.toEqual({ + message: 'Missing targetId', + status: 400, + }); + + expect(unknownTargetResponse.status).toBe(404); + await expect(unknownTargetResponse.json()).resolves.toEqual({ + message: 'Provider target not found', + status: 404, + }); + expect(httpClient.requests).toEqual([]); + } + ); + }); }); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 48e0c22f7..4fcf0e682 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -1,5 +1,6 @@ import cors from 'cors'; import express, { Express, Request, Response } from 'express'; +import { createHash } from 'node:crypto'; import { lookup } from 'node:dns/promises'; import { isIP } from 'node:net'; import zlib from 'node:zlib'; @@ -52,6 +53,8 @@ interface ProviderUrlError { readonly status: number; } +type ProviderTargetRegistry = Map; + export function createWebBackendApp( options: WebBackendAppOptions = {} ): Express { @@ -68,6 +71,7 @@ export function createWebBackendApp( isPrivateNetworkProxyAllowed(), resolveHostname: options.resolveHostname ?? resolveHostname, }; + const providerTargets: ProviderTargetRegistry = new Map(); const corsMiddleware = cors({ origin(origin, callback) { @@ -96,12 +100,39 @@ export function createWebBackendApp( ); }); + app.options('/provider-targets', corsMiddleware); + app.post( + '/provider-targets', + corsMiddleware, + express.json({ limit: '16kb' }), + async (req, res) => { + const rawUrl = + req.body && + typeof req.body === 'object' && + 'url' in req.body && + typeof req.body.url === 'string' + ? req.body.url + : undefined; + + if (!rawUrl) { + res.status(400).json({ message: 'Missing url', status: 400 }); + return; + } + + const result = await validateProviderUrl(rawUrl, providerUrlPolicy); + if ('message' in result) { + res.status(result.status).json(result); + return; + } + + const targetId = createProviderTargetId(result); + providerTargets.set(targetId, result); + res.json({ targetId }); + } + ); + app.get('/parse', corsMiddleware, async (req, res) => { - const url = await getValidatedProviderUrl( - req, - res, - providerUrlPolicy - ); + const url = getRegisteredProviderUrl(req, res, providerTargets); if (!url) { return; } @@ -122,11 +153,7 @@ export function createWebBackendApp( }); app.get('/parse-xml', corsMiddleware, async (req, res) => { - const url = await getValidatedProviderUrl( - req, - res, - providerUrlPolicy - ); + const url = getRegisteredProviderUrl(req, res, providerTargets); if (!url) { return; } @@ -149,11 +176,7 @@ export function createWebBackendApp( }); app.get('/xtream', corsMiddleware, async (req, res) => { - const url = await getValidatedProviderUrl( - req, - res, - providerUrlPolicy - ); + const url = getRegisteredProviderUrl(req, res, providerTargets); if (!url) { return; } @@ -162,7 +185,7 @@ export function createWebBackendApp( const response = await httpClient.get( appendPathSegment(url, 'player_api.php'), { - params: getProxyParams(req, ['url']), + params: getProxyParams(req, ['targetId']), } ); @@ -176,11 +199,7 @@ export function createWebBackendApp( }); app.get('/stalker', corsMiddleware, async (req, res) => { - const url = await getValidatedProviderUrl( - req, - res, - providerUrlPolicy - ); + const url = getRegisteredProviderUrl(req, res, providerTargets); const macAddress = getQueryString(req, 'macAddress'); const token = getQueryString(req, 'token'); if (!url) { @@ -189,7 +208,7 @@ export function createWebBackendApp( try { const response = await httpClient.get(url.href, { - params: getProxyParams(req, ['url']), + params: getProxyParams(req, ['targetId']), headers: { ...(macAddress ? { Cookie: `mac=${macAddress}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -208,24 +227,27 @@ export function createWebBackendApp( return app; } -async function getValidatedProviderUrl( +function getRegisteredProviderUrl( req: Request, res: Response, - policy: ProviderUrlPolicy -): Promise { - const rawUrl = getQueryString(req, 'url'); - if (!rawUrl) { - res.status(400).json({ message: 'Missing url', status: 400 }); + providerTargets: ProviderTargetRegistry +): URL | null { + const targetId = getQueryString(req, 'targetId'); + if (!targetId) { + res.status(400).json({ message: 'Missing targetId', status: 400 }); return null; } - const result = await validateProviderUrl(rawUrl, policy); - if ('message' in result) { - res.status(result.status).json(result); + const targetUrl = providerTargets.get(targetId); + if (!targetUrl) { + res.status(404).json({ + message: 'Provider target not found', + status: 404, + }); return null; } - return result; + return targetUrl; } async function validateProviderUrl( @@ -299,6 +321,10 @@ async function resolveHostname(hostname: string): Promise { return records.map((record) => record.address); } +function createProviderTargetId(url: URL): string { + return createHash('sha256').update(url.href).digest('hex'); +} + function isPrivateNetworkProxyAllowed(): boolean { const value = process.env['IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS']; return value === '1' || value === 'true'; @@ -398,7 +424,9 @@ async function fetchEpgDataFromUrl( ): Promise { const href = url.href; const response = await httpClient.get(href, { - ...(url.pathname.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}), + ...(url.pathname.endsWith('.gz') + ? { responseType: 'arraybuffer' } + : {}), }); const xml = url.pathname.endsWith('.gz') ? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString() @@ -415,9 +443,9 @@ function isPlaylistParseError( ); } -function parsePlaylist( - playlist: string -): { items: Array> } { +function parsePlaylist(playlist: string): { + items: Array>; +} { return parser.parse(playlist) as unknown as { items: Array>; }; diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 18f74b762..aa7b916a1 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -5,7 +5,7 @@ import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from '@iptvnator/m3u-state'; -import { catchError, firstValueFrom, throwError } from 'rxjs'; +import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs'; import { DataService } from '@iptvnator/services'; import { ERROR, @@ -49,6 +49,10 @@ interface ErrorStatus { readonly status?: number; } +interface ProviderTargetRegistration { + readonly targetId: string; +} + @Injectable({ providedIn: 'root', }) @@ -59,6 +63,7 @@ export class PwaService extends DataService { private readonly store = inject(Store); private readonly swUpdate = inject(SwUpdate); private readonly translateService = inject(TranslateService); + private readonly providerTargetIds = new Map>(); private readonly silentXtreamActions = new Set([ XtreamCodeActions.GetAccountInfo, XtreamCodeActions.GetLiveCategories, @@ -270,39 +275,49 @@ export class PwaService extends DataService { }, } : {}; - const requestPayload = { - method: 'GET', - url: `${this.corsProxyUrl}/xtream`, - params: { - url: payload.url, - ...payload.params, - }, - ...(payload.macAddress - ? { - headers: { - Cookie: `mac=${payload.macAddress}`, - }, - } - : {}), - }; - const context = createPortalDebugRequestContext({ + let context = createPortalDebugRequestContext({ provider: 'xtream', operation: payload.params?.action ?? 'unknown', transport: 'pwa-http', - request: requestPayload, + request: { + method: 'GET', + params: payload.params, + url: `${this.corsProxyUrl}/xtream`, + }, }); - logPortalDebugRequest(context); try { + const targetId = await this.getProviderTargetId(payload.url); + const requestParams = { + targetId, + ...payload.params, + }; + const requestPayload = { + method: 'GET', + params: requestParams, + url: `${this.corsProxyUrl}/xtream`, + ...(payload.macAddress + ? { + headers: { + Cookie: `mac=${payload.macAddress}`, + }, + } + : {}), + }; + context = createPortalDebugRequestContext({ + provider: 'xtream', + operation: payload.params?.action ?? 'unknown', + transport: 'pwa-http', + request: requestPayload, + }); + logPortalDebugRequest(context); + let result: PwaErrorResult | PwaXtreamResult; const response = (await firstValueFrom( this.http.get( `${this.corsProxyUrl}/xtream`, { - params: { - url: payload.url, - ...payload.params, - }, + params: requestParams, ...headers, } ) @@ -442,29 +457,38 @@ export class PwaService extends DataService { params: Record; macAddress: string; }) { - const params = new URLSearchParams({ - url: payload.url, - ...payload.params, - macAddress: payload.macAddress, - }); - const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`; - const context = createPortalDebugRequestContext({ + let context = createPortalDebugRequestContext({ provider: 'stalker', operation: payload.params?.action ?? 'unknown', transport: 'pwa-http', request: { method: 'GET', - url: requestUrl, - params: { - url: payload.url, - ...payload.params, - macAddress: payload.macAddress, - }, + params: payload.params, + url: `${this.corsProxyUrl}/stalker`, }, }); - logPortalDebugRequest(context); try { + const targetId = await this.getProviderTargetId(payload.url); + const requestParams = { + targetId, + ...payload.params, + macAddress: payload.macAddress, + }; + const params = new URLSearchParams(requestParams); + const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`; + context = createPortalDebugRequestContext({ + provider: 'stalker', + operation: payload.params?.action ?? 'unknown', + transport: 'pwa-http', + request: { + method: 'GET', + params: requestParams, + url: requestUrl, + }, + }); + logPortalDebugRequest(context); + // Make the fetch request const response = await fetch(requestUrl); @@ -497,9 +521,35 @@ export class PwaService extends DataService { } getPlaylistFromUrl(url: string) { - return this.http.get(`${this.corsProxyUrl}/parse`, { - params: { url }, - }); + return from(this.getProviderTargetId(url)).pipe( + switchMap((targetId) => + this.http.get(`${this.corsProxyUrl}/parse`, { + params: { targetId }, + }) + ) + ); + } + + private getProviderTargetId(url: string): Promise { + const cachedTargetId = this.providerTargetIds.get(url); + if (cachedTargetId) { + return cachedTargetId; + } + + const targetIdRequest = firstValueFrom( + this.http.post( + `${this.corsProxyUrl}/provider-targets`, + { url } + ) + ) + .then((response) => response.targetId) + .catch((error) => { + this.providerTargetIds.delete(url); + throw error; + }); + + this.providerTargetIds.set(url, targetIdRequest); + return targetIdRequest; } removeAllListeners(type: string): void { diff --git a/docker/README.md b/docker/README.md index a1faa0d24..120944a0f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,11 +7,11 @@ You can deploy and run the PWA version of IPTVnator on your own machine with `do This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file. -The web backend proxy accepts only `http` and `https` provider URLs. It blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users. +The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users. For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. -## Build frontend +## Build frontend $ docker build -t 4gray/iptvnator -f docker/Dockerfile .