fix: main process system cert fetches (#10075)

(cherry picked from commit 9c78ccb2a4)
This commit is contained in:
Ryan Willis
2026-06-16 08:46:25 -07:00
committed by Insomnia
parent 6916ba6fb1
commit c9847329d5
5 changed files with 195 additions and 54 deletions

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { insomniaFetch, setFetchImplementation } from '../insomnia-fetch';
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
afterEach(() => {
setFetchImplementation((input, init) => globalThis.fetch(input, init));
});
describe('insomniaFetch', () => {
it('uses the injected fetch implementation', async () => {
const impl = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
setFetchImplementation(impl);
const result = await insomniaFetch<{ ok: boolean }>({
method: 'GET',
path: '/v1/test',
sessionId: 'ses_123',
origin: 'https://api.test',
});
expect(result).toEqual({ ok: true });
expect(impl).toHaveBeenCalledTimes(1);
const [url, init] = impl.mock.calls[0];
expect(url).toBe('https://api.test/v1/test');
expect(init.headers['X-Session-Id']).toBe('ses_123');
});
it('appends the error cause to opaque fetch failures', async () => {
setFetchImplementation(() => {
const err = new TypeError('fetch failed');
(err as Error & { cause?: unknown }).cause = { code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' };
return Promise.reject(err);
});
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
'fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)',
);
});
it('falls back to the cause message when there is no code', async () => {
setFetchImplementation(() => {
const err = new TypeError('fetch failed');
(err as Error & { cause?: unknown }).cause = { message: 'proxy connection refused' };
return Promise.reject(err);
});
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
'fetch failed (proxy connection refused)',
);
});
it('surfaces the code from an AggregateError cause with an empty message', async () => {
setFetchImplementation(() => {
const err = new TypeError('fetch failed');
const connectError = Object.assign(new Error('connect ECONNREFUSED ::1:443'), { code: 'ECONNREFUSED' });
// eslint-disable-next-line unicorn/error-message -- empty message is the point
(err as Error & { cause?: unknown }).cause = new AggregateError([connectError], '');
return Promise.reject(err);
});
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
'fetch failed (ECONNREFUSED)',
);
});
it('appends a string cause', async () => {
setFetchImplementation(() => {
const err = new TypeError('fetch failed');
(err as Error & { cause?: unknown }).cause = 'certificate has expired';
return Promise.reject(err);
});
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
'fetch failed (certificate has expired)',
);
});
it('rethrows errors without a cause unchanged', async () => {
setFetchImplementation(() => Promise.reject(new TypeError('fetch failed')));
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
/^fetch failed$/,
);
});
it('passes non-Error rejections through untouched', async () => {
setFetchImplementation(() => Promise.reject('boom'));
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toBe('boom');
});
it('reports timeouts with method and path', async () => {
setFetchImplementation(() => Promise.reject(new DOMException('The operation timed out.', 'TimeoutError')));
await expect(insomniaFetch({ method: 'POST', path: '/v1/slow', sessionId: 'ses_123' })).rejects.toThrow(
'insomniaFetch timed out: POST /v1/slow',
);
});
it('reports aborts as timeouts', async () => {
setFetchImplementation(() => Promise.reject(new DOMException('The operation was aborted.', 'AbortError')));
await expect(insomniaFetch({ method: 'GET', path: '/v1/test', sessionId: 'ses_123' })).rejects.toThrow(
'insomniaFetch timed out: GET /v1/test',
);
});
});

View File

@@ -3,6 +3,15 @@ import { type FetchConfig, ResponseFailError } from 'insomnia-api';
import { getApiBaseURL, getClientString, INSOMNIA_FETCH_TIME_OUT, PLAYWRIGHT_TEST } from './constants';
import { generateId } from './misc';
type FetchImplementation = (input: string, init?: RequestInit) => Promise<Response>;
// node fetch ignores the system proxy and OS certs — main swaps in net.fetch (entry.main.ts)
let fetchImpl: FetchImplementation = (input, init) => globalThis.fetch(input, init);
export function setFetchImplementation(impl: FetchImplementation) {
fetchImpl = impl;
}
// Adds headers, retries and opens deep links returned from the api
export async function insomniaFetch<T = void>({
method,
@@ -39,7 +48,7 @@ export async function insomniaFetch<T = void>({
}
try {
const response = await fetch((origin || getApiBaseURL()) + path, config);
const response = await fetchImpl((origin || getApiBaseURL()) + path, config);
const uri = response.headers.get('x-insomnia-command');
if (uri && onDeepLink) {
onDeepLink(uri);
@@ -63,7 +72,21 @@ export async function insomniaFetch<T = void>({
}
return isJson ? response.json() : (response.text() as Promise<T>);
} catch (err) {
const error = err.name === 'AbortError' ? new Error('insomniaFetch timed out') : err;
throw error;
if (!(err instanceof Error)) {
throw err;
}
// AbortSignal.timeout() gives TimeoutError, not AbortError
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
throw new Error(`insomniaFetch timed out: ${method} ${path}`, { cause: err });
}
// the real error (ECONNREFUSED, cert problems) hides in err.cause, sometimes nested in an AggregateError
const cause = (err as { cause?: string | { code?: string; message?: string; errors?: { code?: string }[] } })
.cause;
const detail = typeof cause === 'string' ? cause : cause?.code || cause?.errors?.[0]?.code || cause?.message;
if (detail) {
// fresh Error (don't mutate err.message) so a re-observed/retried error doesn't append the detail twice
throw new Error(`${err.message} (${detail})`, { cause: err });
}
throw err;
}
}

View File

@@ -3,7 +3,7 @@ import inspector from 'node:inspector';
import { arch, release } from 'node:os';
import path from 'node:path';
import electron, { app, BrowserWindow, session } from 'electron';
import electron, { app, BrowserWindow, net, session } from 'electron';
import contextMenu from 'electron-context-menu';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { configureFetch } from 'insomnia-api';
@@ -12,7 +12,7 @@ import { database, initDatabase, initServices, models, services } from 'insomnia
import { isMac } from 'insomnia-data/common';
import { servicesNodeImpl } from 'insomnia-data/node';
import { insomniaFetch } from '~/common/insomnia-fetch';
import { insomniaFetch, setFetchImplementation } from '~/common/insomnia-fetch';
import { mainDatabase } from '~/main/database.main';
import { initElectronStorage } from '~/main/electron-storage';
import { runGitCredentialsMigration } from '~/main/git/migrations';
@@ -65,6 +65,11 @@ let openDeepLinkUrl = async (url: string) => {
console.warn('[main] openDeepLinkUrl function not initialized yet, cannot open URL:', url);
};
configureFetch(options => insomniaFetch({ ...options, onDeepLink: (uri: string) => openDeepLinkUrl(uri) }));
// net.fetch picks up the proxy + OS certs like the renderer; node fetch does neither.
// only works post-ready, which is fine — nothing calls this earlier. 'omit' = no cookies, same as before.
setFetchImplementation((input, init) =>
net.fetch(input, { ...init, credentials: 'omit', bypassCustomProtocolHandlers: true }),
);
// Handle potential auto-update
if (checkIfRestartNeeded()) {
@@ -131,10 +136,11 @@ app.on('ready', async () => {
initServices(servicesNodeImpl);
initRuntime(nodeRuntime);
await _createModelInstances();
// proxy has to be set up before backup's net.fetch below
await watchProxySettings();
// backup needs the channel from settings which needs the database
await backupIfNewerVersionAvailable();
sentryWatchAnalyticsEnabled();
watchProxySettings();
await runGitCredentialsMigration();

View File

@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { app, net } from 'electron';
import { app } from 'electron';
import { services } from 'insomnia-data';
import { AnalyticsEvent, trackAnalyticsEvent } from '~/main/analytics';
@@ -100,25 +100,23 @@ export default async function installPlugin(pluginName: string, allowScopedPacka
throw new Error('Invalid plugin metadata: missing tarball URL');
}
// Step 3: Ensure the plugin tarball can be fetched
// Step 3: only allow tarballs from known hosts
let tarballUrl: URL;
try {
// After fetching info, check the info.dist.tarball. This prevents downloading from weird hosts.
const tarballUrl = new URL(info.dist.tarball);
const allowedTarballHostnames = await getAllowedTarballHostnames();
if (!allowedTarballHostnames.includes(tarballUrl.hostname)) {
throw new Error(`Tarball must come from an allowed host. Got: ${tarballUrl.hostname}`);
}
// Fetch the tarball to ensure it's accessible
// This is a simple check to ensure the tarball URL is valid and accessible
const tarballResponse = await net.fetch(info.dist.tarball);
// Check if the response is OK (status code 200)
if (!tarballResponse.ok) {
throw new Error(`Failed to fetch tarball: ${tarballResponse.statusText}`);
}
} catch (err: any) {
throw new Error(`Failed to fetch plugin tarball ${info.dist.tarball}: ${err.message}`);
tarballUrl = new URL(info.dist.tarball);
} catch {
throw new Error(`Invalid tarball URL in plugin metadata: ${info.dist.tarball}`);
}
const allowedTarballHostnames = await getAllowedTarballHostnames();
if (!allowedTarballHostnames.includes(tarballUrl.hostname)) {
throw new Error(`Tarball must come from an allowed host. Got: ${tarballUrl.hostname}`);
}
// and require https, unless it's the user's own http registry (same host:port)
const registryUrl = new URL(await getRegistryUrl());
const isUsersHttpRegistry =
registryUrl.protocol === 'http:' && tarballUrl.protocol === 'http:' && tarballUrl.host === registryUrl.host;
if (tarballUrl.protocol !== 'https:' && !isUsersHttpRegistry) {
throw new Error(`Tarball must be served over https. Got: ${info.dist.tarball}`);
}
// Step 4: Install the plugin into a temporary directory

View File

@@ -9,40 +9,44 @@ async function updateProxy() {
const { proxyEnabled, httpProxy, httpsProxy, noProxy } = await services.settings.get();
if (proxyEnabled) {
// Supported values for proxyUrl are like: http://localhost:8888, https://localhost:8888 or localhost:8888
// This function tries to parse the proxyUrl and return the hostname in order to allow all the above values to work.
function parseProxyFromUrl(proxyUrl: string) {
const url = new URL(setDefaultProtocol(proxyUrl));
return `${url.hostname}${url.port ? `:${url.port}` : ''}`;
}
const proxyRules = [];
if (httpProxy) {
proxyRules.push(`http=${parseProxyFromUrl(httpProxy)}`);
}
if (httpsProxy) {
proxyRules.push(`https=${parseProxyFromUrl(httpsProxy)}`);
}
try {
// Supported values for proxyUrl are like: http://localhost:8888, https://localhost:8888 or localhost:8888
// This function tries to parse the proxyUrl and return the host (host:port) in order to allow all the above values to work.
// url.host keeps IPv6 brackets intact, url.hostname doesn't
function parseProxyFromUrl(proxyUrl: string) {
const url = new URL(setDefaultProtocol(proxyUrl));
return url.host;
}
const proxyRules = [];
if (httpProxy) {
proxyRules.push(`http=${parseProxyFromUrl(httpProxy)}`);
}
if (httpsProxy) {
proxyRules.push(`https=${parseProxyFromUrl(httpsProxy)}`);
}
session.defaultSession.resolveProxy;
// Set proxy rules in the main session https://www.electronjs.org/docs/latest/api/structures/proxy-config
session.defaultSession.setProxy({
proxyRules: proxyRules.join(';'),
proxyBypassRules: [
noProxy,
// getApiBaseURL(),
// @TODO Add all our API urls here to bypass the proxy to work as before with axios.
// We can add an option in settings to use the proxy for insomnia API requests and not include them here.
].join(','),
mode: 'system',
});
return;
// Set proxy rules in the main session https://www.electronjs.org/docs/latest/api/structures/proxy-config
// no mode here — it overrides proxyRules ('system' ignores them)
await session.defaultSession.setProxy({
proxyRules: proxyRules.join(';'),
proxyBypassRules: noProxy ?? '',
});
return;
} catch (err) {
// bad proxy settings shouldn't break startup — fall back to the system proxy
console.warn('[proxy] Failed to apply proxy settings, falling back to system proxy', err);
}
}
try {
await session.defaultSession.setProxy({ proxyRules: '', proxyBypassRules: '', mode: 'system' });
} catch (err) {
console.warn('[proxy] Failed to reset proxy to system', err);
}
session.defaultSession.setProxy({ proxyRules: '', proxyBypassRules: '', mode: 'system' });
}
export async function watchProxySettings() {
let old = await services.settings.get();
updateProxy();
await updateProxy();
db.onChange(async (changes: ChangeBufferEvent[]) => {
for (const change of changes) {
const [event, doc] = change;
@@ -54,7 +58,7 @@ export async function watchProxySettings() {
old.httpsProxy !== doc.httpsProxy ||
old.noProxy !== doc.noProxy;
if (hasProxyChanged) {
updateProxy();
await updateProxy();
old = doc;
}
}