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,