From 20d0f01428e5eb58daddffb359d497700dfed0f3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 14 Jun 2026 13:43:09 +0200 Subject: [PATCH] fix(xtream): address portal review feedback --- apps/web-backend/project.json | 3 +- .../src/app/web-backend-app.spec.ts | 30 +++++++++++ apps/web-backend/src/app/web-backend-app.ts | 44 ++++++++++++--- .../xtream-portal-compatibility.md | 9 ++++ .../xtream-code-import.component.spec.ts | 18 +++++++ .../xtream-code-import.component.ts | 49 +++++++++++++---- .../lib/services/xtream-url.service.spec.ts | 39 ++++++++++++++ .../src/lib/services/xtream-url.service.ts | 54 +++++++++++++++---- .../features/with-portal.feature.spec.ts | 18 +++++++ .../stores/features/with-portal.feature.ts | 34 ++++++------ .../src/lib/portal-status.service.spec.ts | 26 +++++++++ .../services/src/lib/portal-status.service.ts | 22 +++++--- 12 files changed, 295 insertions(+), 51 deletions(-) diff --git a/apps/web-backend/project.json b/apps/web-backend/project.json index 88fa476fb..a29059af2 100644 --- a/apps/web-backend/project.json +++ b/apps/web-backend/project.json @@ -31,7 +31,8 @@ "PORT": "3333", "CLIENT_URL": "http://localhost:4200", "BACKEND_URL": "/api", - "IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1" + "IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1", + "TSX_TSCONFIG_PATH": "tsconfig.base.json" } } }, 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 17b682650..e9ab7f2a8 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -467,6 +467,36 @@ https://stream.example/news.m3u8`); ); }); + it('rejects Xtream proxy calls when a registered target no longer passes the URL policy', async () => { + const httpClient = new StubHttpClient(); + const resolvedAddresses = [['93.184.216.34'], ['127.0.0.1']]; + + await withServer( + createWebBackendApp({ + httpClient, + resolveHostname: async () => + resolvedAddresses.shift() ?? ['127.0.0.1'], + }), + async (baseUrl) => { + const targetId = await registerProviderTarget( + baseUrl, + 'http://xtream.example' + ); + const response = await fetch( + `${baseUrl}/xtream?targetId=${targetId}&action=get_account_info` + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + message: + 'Provider URL points to a private or local network address', + status: 400, + }); + expect(httpClient.requests).toEqual([]); + } + ); + }); + it('allows private target URLs when explicitly enabled for local self-hosted testing', async () => { const httpClient = new StubHttpClient(); httpClient.queueResponse({ user_info: { username: 'demo' } }); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 131ae975a..32bd75abe 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -177,16 +177,30 @@ export function createWebBackendApp( }); app.get('/xtream', corsMiddleware, async (req, res) => { - const url = getRegisteredProviderUrl(req, res, providerTargets); - if (!url) { + const registeredUrl = getRegisteredProviderUrl( + req, + res, + providerTargets + ); + if (!registeredUrl) { return; } + const url = new URL(registeredUrl.href); try { + const providerUrlError = await normalizeAndValidateXtreamProviderUrl( + url, + providerUrlPolicy + ); + if (providerUrlError) { + res.status(providerUrlError.status).json(providerUrlError); + return; + } + // Provider URLs are validated by /provider-targets before they enter the registry. // codeql[js/request-forgery] const response = await httpClient.get( - buildXtreamPlayerApiUrl(url), + appendPathSegment(url, 'player_api.php'), { params: getProxyParams(req, ['targetId']), } @@ -395,11 +409,27 @@ function appendPathSegment(url: URL, segment: string): string { return nextUrl.href; } -function buildXtreamPlayerApiUrl(url: URL): string { - return appendPathSegment( - new URL(normalizeXtreamServerUrl(url.href)), - 'player_api.php' +async function normalizeAndValidateXtreamProviderUrl( + url: URL, + policy: ProviderUrlPolicy +): Promise { + let normalizedUrl: URL; + try { + normalizedUrl = new URL(normalizeXtreamServerUrl(url.href)); + } catch { + return { message: 'Provider URL is not a valid URL', status: 400 }; + } + + const validatedUrl = await validateProviderUrl( + appendPathSegment(normalizedUrl, 'player_api.php'), + policy ); + if ('message' in validatedUrl) { + return validatedUrl; + } + + url.href = normalizedUrl.href; + return null; } async function handlePlaylistParse(options: { diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index bbe1f628e..39605087b 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -57,9 +57,18 @@ Electron IPC and the PWA backend both construct API requests by appending Credentials sent to the API are trimmed before serialization. +The PWA backend only proxies Xtream requests through registered provider +targets. Those targets are validated when registered and revalidated before the +`/xtream` proxy request, including protocol, URL credentials, DNS resolution, +and private-network checks. + ## Playback URL Formats When account info includes `user_info.allowed_output_formats`, the current Xtream playlist keeps those formats for the active session. Live stream URL construction falls back to the first provider-allowed format when the selected application format is not allowed by the portal. + +If stored Xtream playback credentials contain an invalid server URL or blank +username/password, stream URL construction returns an empty URL instead of +throwing during playback. diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts index 1723c0126..2fd775d9d 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -40,6 +40,24 @@ describe('XtreamCodeImportComponent', () => { expect(component.form.valid).toBe(false); }); + it('rejects URLs with inline credentials before add or test actions', async () => { + component.form.patchValue({ + title: 'Portal', + serverUrl: 'https://user:pass@example.com', + username: 'user', + password: 'pass', + }); + + expect(component.form.valid).toBe(false); + + await component.testConnection(); + component.addPlaylist(); + + expect(component.isTestingConnection).toBe(false); + expect(portalStatusService.checkPortalStatus).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + it('extracts and trims username and password from a full Xtream URL', () => { component.extractParams( 'https://example.com/get.php?username=%20user%20&password=%20pass%20&type=m3u_plus' diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 24f877a87..77d1a8497 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -1,9 +1,11 @@ import { Component, EventEmitter, Output, inject } from '@angular/core'; import { + AbstractControl, FormControl, FormGroup, FormsModule, ReactiveFormsModule, + ValidationErrors, Validators, } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -20,6 +22,22 @@ import { } from '@iptvnator/shared/interfaces'; import { v4 as uuid } from 'uuid'; +function xtreamServerUrlValidator( + control: AbstractControl +): ValidationErrors | null { + const value = control.value; + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + + try { + normalizeXtreamServerUrl(value); + return null; + } catch { + return { xtreamServerUrl: true }; + } +} + @Component({ imports: [ FormsModule, @@ -80,6 +98,7 @@ export class XtreamCodeImportComponent { serverUrl: new FormControl('', [ Validators.required, Validators.pattern(this.URL_REGEX), + xtreamServerUrlValidator, ]), importDate: new FormControl(new Date().toISOString()), }); @@ -93,9 +112,13 @@ export class XtreamCodeImportComponent { async testConnection(): Promise { if (!this.form.valid) return; - this.isTestingConnection = true; const connection = this.getNormalizedConnection(); + if (!connection) { + this.connectionStatus = 'unavailable'; + return; + } + this.isTestingConnection = true; try { // User-initiated connection test — bypass the shared cache so the // result reflects the portal's current state, not whatever was @@ -140,6 +163,10 @@ export class XtreamCodeImportComponent { if (!this.form.valid) return; const connection = this.getNormalizedConnection(); + if (!connection) { + return; + } + this.store.dispatch( PlaylistActions.addPlaylist({ playlist: { @@ -176,13 +203,17 @@ export class XtreamCodeImportComponent { password: string; serverUrl: string; username: string; - } { - return { - password: (this.form.value.password as string).trim(), - serverUrl: normalizeXtreamServerUrl( - this.form.value.serverUrl as string - ), - username: (this.form.value.username as string).trim(), - }; + } | null { + try { + return { + password: (this.form.value.password as string).trim(), + serverUrl: normalizeXtreamServerUrl( + this.form.value.serverUrl as string + ), + username: (this.form.value.username as string).trim(), + }; + } catch { + return null; + } } } diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index eb23461a2..92db66b06 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -1,6 +1,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { DatabaseService, SettingsStore } from '@iptvnator/services'; +import { + XtreamSerieEpisode, + XtreamVodDetails, +} from '@iptvnator/shared/interfaces'; import { XtreamCredentials } from './xtream-api.service'; import { XtreamUrlService } from './xtream-url.service'; @@ -69,6 +73,41 @@ describe('XtreamUrlService', () => { expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8'); }); + it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => { + const invalidCredentials: XtreamCredentials = { + ...credentials, + serverUrl: 'https://demo:secret@demo.example', + }; + const vodItem: XtreamVodDetails = { + movie_data: { + added: '', + category_id: '', + container_extension: 'mp4', + custom_sid: null, + direct_source: '', + name: 'Movie', + stream_id: 101, + }, + }; + const episode: XtreamSerieEpisode = { + added: '', + container_extension: 'mp4', + custom_sid: '', + direct_source: '', + episode_num: 1, + id: '202', + info: [], + season: 1, + title: 'Episode', + }; + + expect(service.constructLiveUrl(invalidCredentials, 101)).toBe(''); + expect(service.constructVodUrl(invalidCredentials, vodItem)).toBe(''); + expect(service.constructEpisodeUrl(invalidCredentials, episode)).toBe( + '' + ); + }); + it('detects the legacy catchup scheme once and then uses the cached result', async () => { const xtreamProbeUrl = jest .fn() diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index cf926cea8..0ec6014db 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -33,6 +33,14 @@ type XtreamVodStreamLike = XtreamVodDetails & { type XtreamCatchupScheme = 'rest' | 'legacy'; +interface NormalizedXtreamCredentials { + password: string; + rawPassword: string; + rawUsername: string; + serverUrl: string; + username: string; +} + type XtreamProbeApi = { xtreamProbeUrl?: ( url: string, @@ -69,6 +77,10 @@ export class XtreamUrlService { format?: string ): string { const normalizedCredentials = this.normalizeCredentials(credentials); + if (!normalizedCredentials) { + return ''; + } + const streamFormat = this.resolveLiveStreamFormat( credentials, format ?? this.settingsStore.streamFormat() ?? 'ts' @@ -91,6 +103,10 @@ export class XtreamUrlService { return ''; } const normalizedCredentials = this.normalizeCredentials(credentials); + if (!normalizedCredentials) { + return ''; + } + return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`; } @@ -103,6 +119,10 @@ export class XtreamUrlService { episode: XtreamSerieEpisode ): string { const normalizedCredentials = this.normalizeCredentials(credentials); + if (!normalizedCredentials) { + return ''; + } + return `${normalizedCredentials.serverUrl}/series/${normalizedCredentials.username}/${normalizedCredentials.password}/${episode.id}.${episode.container_extension}`; } @@ -114,6 +134,11 @@ export class XtreamUrlService { scheme: XtreamCatchupScheme, serverTimezone?: string ): string { + const normalizedCredentials = this.normalizeCredentials(credentials); + if (!normalizedCredentials) { + return ''; + } + const durationMinutes = Math.max( 1, Math.round((stopTimestamp - startTimestamp) / 60) @@ -124,8 +149,6 @@ export class XtreamUrlService { ); if (scheme === 'legacy') { - const normalizedCredentials = - this.normalizeCredentials(credentials); const params = new URLSearchParams({ username: normalizedCredentials.rawUsername, password: normalizedCredentials.rawPassword, @@ -136,7 +159,6 @@ export class XtreamUrlService { return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`; } - const normalizedCredentials = this.normalizeCredentials(credentials); return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`; } @@ -232,6 +254,10 @@ export class XtreamUrlService { serverTimezone ); + if (!restUrl || !legacyUrl) { + return 'rest'; + } + const restStatus = await this.probeCatchupUrl(restUrl); let detectedScheme: XtreamCatchupScheme; @@ -276,21 +302,27 @@ export class XtreamUrlService { ); } - private normalizeCredentials(credentials: XtreamCredentials): { - password: string; - rawPassword: string; - rawUsername: string; - serverUrl: string; - username: string; - } { + private normalizeCredentials( + credentials: XtreamCredentials + ): NormalizedXtreamCredentials | null { const rawUsername = credentials.username.trim(); const rawPassword = credentials.password.trim(); + if (!rawUsername || !rawPassword) { + return null; + } + + let serverUrl: string; + try { + serverUrl = normalizeXtreamServerUrl(credentials.serverUrl); + } catch { + return null; + } return { password: encodeURIComponent(rawPassword), rawPassword, rawUsername, - serverUrl: normalizeXtreamServerUrl(credentials.serverUrl), + serverUrl, username: encodeURIComponent(rawUsername), }; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index 907b563dc..c15138a7b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -82,4 +82,22 @@ describe('withPortal', () => { expect(store.currentPlaylist()?.allowedOutputFormats).toEqual(['m3u8']); }); + + it('clears stale allowed output formats when account info omits them', async () => { + store.setCurrentPlaylist({ + ...PLAYLIST, + allowedOutputFormats: ['m3u8'], + }); + apiService.getAccountInfo.mockResolvedValue({ + user_info: { + auth: 1, + exp_date: '0', + status: 'Active', + }, + }); + + await store.checkPortalStatus(); + + expect(store.currentPlaylist()?.allowedOutputFormats).toBeUndefined(); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index c00657339..e196c89d0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -120,24 +120,24 @@ export function withPortal() { resolveXtreamPortalStatus(response); const serverTimezone = response?.server_info?.timezone ?? undefined; - const allowedOutputFormats = - response?.user_info?.allowed_output_formats; + const allowedOutputFormats = response?.user_info + ?.allowed_output_formats?.length + ? response.user_info.allowed_output_formats + .map((format) => format.trim()) + .filter(Boolean) + : undefined; patchState(store, { portalStatus }); - if (serverTimezone || allowedOutputFormats?.length) { - const current = store.currentPlaylist(); - if (current) { - patchState(store, { - currentPlaylist: { - ...current, - ...(serverTimezone - ? { serverTimezone } - : {}), - ...(allowedOutputFormats?.length - ? { allowedOutputFormats } - : {}), - }, - }); - } + const current = store.currentPlaylist(); + if (current) { + patchState(store, { + currentPlaylist: { + ...current, + allowedOutputFormats, + ...(serverTimezone + ? { serverTimezone } + : {}), + }, + }); } return portalStatus; } catch (error) { diff --git a/libs/services/src/lib/portal-status.service.spec.ts b/libs/services/src/lib/portal-status.service.spec.ts index 83e62403f..3f0f8f2a3 100644 --- a/libs/services/src/lib/portal-status.service.spec.ts +++ b/libs/services/src/lib/portal-status.service.spec.ts @@ -110,6 +110,32 @@ describe('PortalStatusService', () => { ); }); + it('reads cached status with the same normalized connection key used by checks', async () => { + dataService.sendIpcEvent.mockResolvedValue({ + payload: { + user_info: { + auth: 1, + exp_date: '0', + status: 'Active', + }, + }, + }); + + await service.checkPortalStatus( + ' https://example.com/get.php?username=old&password=old&type=m3u_plus ', + ' user ', + ' pass ' + ); + + expect( + service.getCachedStatus( + ' https://example.com/get.php?username=old&password=old&type=m3u_plus ', + ' user ', + ' pass ' + ) + ).toBe('active'); + }); + it('falls back to alternate account actions when get_account_info does not return user info', async () => { dataService.sendIpcEvent.mockImplementation( async (_type: string, payload: unknown) => { diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index 537c03c2e..f53a80e83 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -137,8 +137,21 @@ export class PortalStatusService { username: string, password: string ): PortalStatus | null { + const connection = this.normalizeConnection( + serverUrl, + username, + password + ); + if (!connection) { + return null; + } + const cached = this.cache.get( - this.buildCacheKey(serverUrl, username, password) + this.buildCacheKey( + connection.serverUrl, + connection.username, + connection.password + ) ); if (!cached) { return null; @@ -193,8 +206,6 @@ export class PortalStatusService { username: string, password: string ): Promise { - let fallbackStatus: PortalStatus = 'unavailable'; - for (const action of XTREAM_STATUS_ACTIONS) { try { const response = @@ -214,13 +225,12 @@ export class PortalStatusService { if (status !== 'unavailable') { return status; } - fallbackStatus = status; } catch { - fallbackStatus = 'unavailable'; + // Try the next Xtream account-info action variant. } } - return fallbackStatus; + return 'unavailable'; } /**