From f856226dc360a1a02591bc9d87ece48f6b97ed4b Mon Sep 17 00:00:00 2001 From: sh3rencr Date: Sat, 11 Jul 2026 20:49:12 +0200 Subject: [PATCH] fix(xtream): send player-style User-Agent to avoid WAF challenge blocking connections (#1170) Some Xtream panels sit behind Cloudflare/WAF rules that challenge generic browser-looking User-Agents while allowlisting known IPTV player clients. The previous hardcoded, truncated browser-style User-Agent in xtream.events.ts (XTREAM_REQUEST and XTREAM_PROBE_URL) was being served a Cloudflare challenge page (HTTP 403 HTML) instead of the real API response, so "Test Connection" always failed with "Could not connect to the portal" even though the same portal worked fine via curl (with a player-style UA) and Safari. Switching to a shared VLC-style User-Agent constant across all three request sites resolves it, verified against a live panel. Co-authored-by: Sergio Herencias Redondo Co-authored-by: Claude Sonnet 5 --- .../src/app/events/xtream.events.spec.ts | 42 +++++++++++++++++++ .../src/app/events/xtream.events.ts | 14 ++++--- .../xtream-portal-compatibility.md | 13 ++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 837d7da2b..5a6840fe0 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -86,6 +86,45 @@ describe('XtreamEvents session cancellation', () => { expect(requestedUrl.searchParams.get('username')).toBe('user'); }); + it('sends a player-style User-Agent instead of a truncated browser string', async () => { + // Regression: some Xtream panels sit behind a WAF (e.g. Cloudflare) + // that challenges generic/incomplete browser User-Agents but + // allowlists known IPTV player clients. The previous hardcoded + // 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + // string was rejected by such panels even though curl/Safari worked. + const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); + expect(requestHandler).toBeDefined(); + + axiosMock.mockResolvedValue({ + status: 200, + data: { user_info: { auth: 1, status: 'Active' } }, + headers: {}, + }); + + await requestHandler?.( + {}, + { + url: 'https://example.com', + params: { + action: 'get_account_info', + password: 'pass', + username: 'user', + }, + suppressErrorLog: true, + } + ); + + const requestConfig = axiosMock.mock.calls[0][0] as { + headers?: Record; + }; + const userAgent = requestConfig.headers?.['User-Agent']; + expect(userAgent).toBeDefined(); + expect(userAgent).not.toBe( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ); + expect(userAgent).not.toMatch(/^Mozilla\/5\.0 /); + }); + it('follows validated redirects for range GET media probes', async () => { const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); const destroyProbeBody = jest.fn(); @@ -129,6 +168,9 @@ describe('XtreamEvents session cancellation', () => { expect(firstRequest.headers).toEqual( expect.objectContaining({ Range: 'bytes=0-4095' }) ); + expect(firstRequest.headers?.['User-Agent']).not.toBe( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ); expect(firstRequest.maxRedirects).toBe(0); expect(firstRequest.responseType).toBe('stream'); expect(destroyProbeBody).toHaveBeenCalledTimes(1); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index c712d12f3..da96caa5e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -14,6 +14,11 @@ import { emitPortalDebugEvent } from './portal-debug.events'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to +// challenge generic browser-looking User-Agents while allowlisting known +// IPTV player clients. A VLC-style User-Agent reliably passes those checks. +const XTREAM_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18'; + export default class XtreamEvents { static bootstrapXtreamEvents(): Electron.IpcMain { return ipcMain; @@ -119,8 +124,7 @@ ipcMain.handle( method: 'GET', url: apiUrl.toString(), headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': XTREAM_CLIENT_USER_AGENT, Accept: 'application/json', }, timeout: 30000, // 30 seconds timeout for Xtream API @@ -194,8 +198,7 @@ ipcMain.handle( method: 'GET', url: apiUrl, headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': XTREAM_CLIENT_USER_AGENT, Accept: 'application/json', }, timeout: 30000, @@ -304,8 +307,7 @@ ipcMain.handle( method, url: payload.url, headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': XTREAM_CLIENT_USER_AGENT, ...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}), }, timeout: 10000, diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index ec1f7b5e8..4572bc1cb 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -62,6 +62,19 @@ targets. Those targets are validated when registered and revalidated before the `/xtream` proxy request, including protocol, URL credentials, DNS resolution, and private-network checks. +## User-Agent + +Electron's `XTREAM_REQUEST` and `XTREAM_PROBE_URL` handlers +(`apps/electron-backend/src/app/events/xtream.events.ts`) send a shared +`XTREAM_CLIENT_USER_AGENT` constant on every outgoing request. Some Xtream +panels sit behind a WAF (e.g. Cloudflare) configured to challenge +generic/incomplete browser-looking User-Agents while allowlisting known IPTV +player clients; a player-style User-Agent (currently a VLC signature) avoids +that challenge page, whereas a browser-looking but non-browser TLS/HTTP client +(axios/curl with a Chrome or empty User-Agent) can be blocked even though a +real browser or a VLC-style client passes. Keep all three request sites using +the shared constant instead of inlining the string again. + ## Playback URL Formats When account info includes `user_info.allowed_output_formats`, the current