diff --git a/apps/electron-backend/src/app/events/url-safety.ts b/apps/electron-backend/src/app/events/url-safety.ts index a3a120719..bfecb3402 100644 --- a/apps/electron-backend/src/app/events/url-safety.ts +++ b/apps/electron-backend/src/app/events/url-safety.ts @@ -24,6 +24,11 @@ export interface RemoteUrlPolicy { * origin; cross-origin redirects must still resolve to public addresses. */ allowPrivateNetworkRedirects?: boolean; + /** + * When true together with allowPrivateNetworks, hostnames are still resolved + * for socket pinning. Private/reserved resolved addresses are accepted. + */ + pinAllowedPrivateNetworkHosts?: boolean; /** Injectable DNS resolver. Defaults to `dns.lookup`; overridable in tests. */ resolveHostname?: (hostname: string) => Promise; } @@ -219,11 +224,38 @@ export async function validateRemoteUrl( throw new UnsafeUrlError('URL credentials are not supported'); } + const hostname = normalizeHostname(url.hostname); if (policy.allowPrivateNetworks) { - return { url }; + if (!policy.pinAllowedPrivateNetworkHosts) { + return { url }; + } + + if (isIP(hostname) !== 0) { + return { url, addresses: [hostname] }; + } + + const resolveHostname = + policy.resolveHostname ?? defaultResolveHostname; + let addresses: readonly string[]; + try { + addresses = await resolveHostname(hostname); + } catch { + throw new UnsafeUrlError('URL host could not be resolved'); + } + + if ( + addresses.length === 0 || + addresses.some((address) => isIP(normalizeHostname(address)) === 0) + ) { + throw new UnsafeUrlError('URL host could not be resolved'); + } + + return { + url, + addresses: addresses.map((address) => normalizeHostname(address)), + }; } - const hostname = normalizeHostname(url.hostname); if (isLocalHostname(hostname) || isPrivateOrReservedIp(hostname)) { throw new UnsafeUrlError( 'URL points to a private or local network address' 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 2f6b88327..837d7da2b 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -142,14 +142,14 @@ describe('XtreamEvents session cancellation', () => { status: 302, headers: { location: 'http://127.0.0.1/admin' }, config: { - url: 'https://portal.example/streaming/timeshift.php?stream=45', + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', }, }); const result = (await probeHandler?.( {}, { - url: 'https://portal.example/streaming/timeshift.php?stream=45', + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', method: 'GET', } )) as { error?: string; status: number; url: string }; @@ -157,7 +157,7 @@ describe('XtreamEvents session cancellation', () => { expect(result).toEqual({ error: 'URL points to a private or local network address', status: 0, - url: 'https://portal.example/streaming/timeshift.php?stream=45', + url: 'http://localhost:3211/streaming/timeshift.php?stream=45', }); expect(axiosMock).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 de46618dd..c712d12f3 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -320,6 +320,7 @@ ipcMain.handle( { allowPrivateNetworkRedirects: false, allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, } ); const responseBody = response.data as 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 a6ee08e29..28f58b7c7 100644 --- a/apps/electron-backend/src/app/util/validated-axios.spec.ts +++ b/apps/electron-backend/src/app/util/validated-axios.spec.ts @@ -204,6 +204,54 @@ describe('requestWithValidatedRedirects', () => { expect(axiosMock).toHaveBeenCalledTimes(1); }); + it('reuses the initially validated addresses for same-origin redirects when private redirect access is disabled', async () => { + axiosMock + .mockResolvedValueOnce({ + status: 302, + headers: { location: '/media.ts' }, + }) + .mockResolvedValueOnce({ + status: 206, + headers: {}, + data: 'ok', + }); + const { factory, lookups } = createCapturingAgentFactory(); + const resolveHostname = jest.fn(async () => ['93.184.216.34']); + + const response = await requestWithValidatedRedirects( + 'https://portal.example/start', + { agentFactory: factory, method: 'GET' }, + { + allowPrivateNetworkRedirects: false, + allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, + resolveHostname, + } + ); + + const resolvePinnedAddress = async (callIndex: number) => { + return new Promise((resolve, reject) => { + lookups[callIndex]( + 'portal.example', + { all: false, family: 0 } as LookupOptions, + (error, address) => { + if (error) { + reject(error); + return; + } + resolve(address); + } + ); + }); + }; + + expect(response.status).toBe(206); + expect(resolveHostname).toHaveBeenCalledTimes(1); + expect(factory.createHttpsAgent).toHaveBeenCalledTimes(2); + await expect(resolvePinnedAddress(0)).resolves.toBe('93.184.216.34'); + await expect(resolvePinnedAddress(1)).resolves.toBe('93.184.216.34'); + }); + 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 a7979a622..6b5e7fa4c 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -147,7 +147,10 @@ function getRedirectValidationPolicy( const parsedUrl = new URL(currentUrl); if (parsedUrl.origin === initialOrigin) { - return policy; + return { + ...policy, + pinAllowedPrivateNetworkHosts: false, + }; } return { @@ -173,6 +176,7 @@ export async function requestWithValidatedRedirects( let currentUrl = rawUrl; let requestConfig = { ...config }; let initialOrigin: string | undefined; + let initialAddresses: readonly string[] | undefined; for (let redirectCount = 0; ; redirectCount += 1) { const validatedTarget = await validateRemoteUrl( @@ -180,11 +184,22 @@ export async function requestWithValidatedRedirects( getRedirectValidationPolicy(currentUrl, initialOrigin, policy) ); const validatedUrl = validatedTarget.url; - initialOrigin ??= validatedUrl.origin; + const isInitialRequest = !initialOrigin; + if (isInitialRequest) { + initialOrigin = validatedUrl.origin; + initialAddresses = validatedTarget.addresses; + } + const addresses = + !isInitialRequest && + policy.allowPrivateNetworks && + policy.allowPrivateNetworkRedirects === false && + validatedUrl.origin === initialOrigin + ? initialAddresses + : validatedTarget.addresses; const pinnedConfig = pinRequestToValidatedAddresses( requestConfig, validatedUrl, - validatedTarget.addresses + addresses ); const response = await axios({ ...pinnedConfig, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 9f5cb0eb4..34b5de2c6 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -134,11 +134,13 @@ 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. +the same provider origin should pass both `allowPrivateNetworkRedirects: false` +and `pinAllowedPrivateNetworkHosts: true` to the validated Axios helper. This +keeps same-origin LAN redirects working, reuses the initially resolved addresses +for same-origin redirect hops, and requires cross-origin redirects to resolve to +public addresses. A provider-controlled URL cannot bounce the Electron main +process to another private or loopback host or rebind the same hostname between +redirect hops. Remote playlist TLS certificates are validated by default. The renderer can persist a host-scoped invalid-certificate trust decision for a