fix(electron): pin private probe redirect hosts

This commit is contained in:
4gray committed 2026-06-27 12:56:16 +02:00
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,
+7 -5
View File
@@ -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