fix(electron): block private xtream probe redirects

This commit is contained in:
4gray committed 2026-06-27 12:49:33 +02:00
1 parent 1679373bb2
commit 16a93bcaaf
6 files changed
+123 -2

No files matched your search

@@ -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<readonly string[]>;
}
@@ -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);
@@ -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 }
@@ -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({
@@ -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<T = unknown>(
((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,
+7
View File
@@ -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