mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(electron): pin private probe redirect hosts
This commit is contained in:
1 parent
16a93bcaaf
commit
ca81bc552f
6 files changed
+111
-13
No files matched your search
@@ -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<readonly string[]>;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -320,6 +320,7 @@ ipcMain.handle(
|
||||
{
|
||||
allowPrivateNetworkRedirects: false,
|
||||
allowPrivateNetworks: true,
|
||||
pinAllowedPrivateNetworkHosts: true,
|
||||
}
|
||||
);
|
||||
const responseBody = response.data as
|
||||
|
||||
@@ -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<string | LookupAddress[]>((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({
|
||||
|
||||
@@ -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<T = unknown>(
|
||||
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<T = unknown>(
|
||||
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<T>({
|
||||
...pinnedConfig,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user