mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(electron): block private xtream probe redirects
This commit is contained in:
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user