From 16a93bcaaf13c82c37ce68ffab65f29035526e92 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 27 Jun 2026 12:49:33 +0200 Subject: [PATCH] fix(electron): block private xtream probe redirects --- .../src/app/events/url-safety.ts | 5 ++ .../src/app/events/xtream.events.spec.ts | 28 +++++++++++ .../src/app/events/xtream.events.ts | 5 +- .../src/app/util/validated-axios.spec.ts | 49 +++++++++++++++++++ .../src/app/util/validated-axios.ts | 31 +++++++++++- docs/architecture/electron-security.md | 7 +++ 6 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/electron-backend/src/app/events/url-safety.ts b/apps/electron-backend/src/app/events/url-safety.ts index 501f627fc..a3a120719 100644 --- a/apps/electron-backend/src/app/events/url-safety.ts +++ b/apps/electron-backend/src/app/events/url-safety.ts @@ -19,6 +19,11 @@ export class UnsafeUrlError extends Error { export interface RemoteUrlPolicy { /** When true, private/reserved targets are permitted (default false). */ allowPrivateNetworks?: boolean; + /** + * When false, allowPrivateNetworks only applies to the initially validated + * origin; cross-origin redirects must still resolve to public addresses. + */ + allowPrivateNetworkRedirects?: boolean; /** Injectable DNS resolver. Defaults to `dns.lookup`; overridable in tests. */ resolveHostname?: (hostname: string) => Promise; } 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 7a0125268..2f6b88327 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -134,6 +134,34 @@ describe('XtreamEvents session cancellation', () => { expect(destroyProbeBody).toHaveBeenCalledTimes(1); }); + it('rejects private cross-origin redirects for range GET media probes', async () => { + const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL'); + expect(probeHandler).toBeDefined(); + + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'http://127.0.0.1/admin' }, + config: { + url: 'https://portal.example/streaming/timeshift.php?stream=45', + }, + }); + + const result = (await probeHandler?.( + {}, + { + url: 'https://portal.example/streaming/timeshift.php?stream=45', + method: 'GET', + } + )) as { error?: string; status: number; url: string }; + + expect(result).toEqual({ + error: 'URL points to a private or local network address', + status: 0, + url: 'https://portal.example/streaming/timeshift.php?stream=45', + }); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + it('aborts requests that were registered with only a session id', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index c00423a7e..de46618dd 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -317,7 +317,10 @@ ipcMain.handle( const response = await requestWithValidatedRedirects( payload.url, config, - { allowPrivateNetworks: true } + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + } ); const responseBody = response.data as | { destroy?: () => void } diff --git a/apps/electron-backend/src/app/util/validated-axios.spec.ts b/apps/electron-backend/src/app/util/validated-axios.spec.ts index ff4146eca..a6ee08e29 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -155,6 +155,55 @@ describe('requestWithValidatedRedirects', () => { expect(requestConfig).not.toHaveProperty('agentFactory'); }); + it('allows same-origin private redirects when private redirect access is disabled', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/media.ts' }, + }) + .mockResolvedValueOnce({ + status: 206, + headers: {}, + data: 'ok', + }); + + const response = await requestWithValidatedRedirects( + 'http://192.168.1.10/start', + { method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + } + ); + + expect(response.status).toBe(206); + expect(axiosMock).toHaveBeenCalledTimes(2); + expect(axiosMock.mock.calls[1][0].url).toBe( + 'http://192.168.1.10/media.ts' + ); + }); + + it('rejects cross-origin private redirects when private redirect access is disabled', async () => { + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { location: 'http://127.0.0.1/admin' }, + }); + + await expect( + requestWithValidatedRedirects( + 'http://192.168.1.10/start', + { method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + } + ) + ).rejects.toMatchObject({ + message: 'URL points to a private or local network address', + }); + expect(axiosMock).toHaveBeenCalledTimes(1); + }); + it('revalidates and repins every redirect hop', async () => { axiosMock .mockResolvedValueOnce({ diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index ab554bd8a..a7979a622 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -132,6 +132,30 @@ function pinRequestToValidatedAddresses( }; } +function getRedirectValidationPolicy( + currentUrl: string, + initialOrigin: string | undefined, + policy: RemoteUrlPolicy +): RemoteUrlPolicy { + if ( + !initialOrigin || + !policy.allowPrivateNetworks || + policy.allowPrivateNetworkRedirects !== false + ) { + return policy; + } + + const parsedUrl = new URL(currentUrl); + if (parsedUrl.origin === initialOrigin) { + return policy; + } + + return { + ...policy, + allowPrivateNetworks: false, + }; +} + /** * Runs an Axios request while validating the initial URL and every redirect. * Redirects are followed manually so each target passes through the same @@ -148,10 +172,15 @@ export async function requestWithValidatedRedirects( ((status: number) => status >= 200 && status < 300); let currentUrl = rawUrl; let requestConfig = { ...config }; + let initialOrigin: string | undefined; for (let redirectCount = 0; ; redirectCount += 1) { - const validatedTarget = await validateRemoteUrl(currentUrl, policy); + const validatedTarget = await validateRemoteUrl( + currentUrl, + getRedirectValidationPolicy(currentUrl, initialOrigin, policy) + ); const validatedUrl = validatedTarget.url; + initialOrigin ??= validatedUrl.origin; const pinnedConfig = pinRequestToValidatedAddresses( requestConfig, validatedUrl, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 6f4d581fe..9f5cb0eb4 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -133,6 +133,13 @@ configured Xtream, Stalker, and playlist providers retain private-network support, but still require HTTP(S), reject embedded credentials, and validate redirects. +Callers that allow private-network provider URLs but only need redirects within +the same provider origin should pass `allowPrivateNetworkRedirects: false` to +the validated Axios helper. This keeps same-origin LAN redirects working while +requiring cross-origin redirects to resolve to public addresses, so a +provider-controlled URL cannot bounce the Electron main process to another +private or loopback host. + Remote playlist TLS certificates are validated by default. The renderer can persist a host-scoped invalid-certificate trust decision for a playlist or EPG source host. The `IPTVNATOR_ALLOW_INSECURE_TLS=1` escape hatch