From c9847329d5d2222af45eafa2a2eaf2e40e4fbc9d Mon Sep 17 00:00:00 2001 From: Ryan Willis Date: Tue, 16 Jun 2026 08:46:25 -0700 Subject: [PATCH] fix: main process system cert fetches (#10075) (cherry picked from commit 9c78ccb2a4972334b63e387d9baf252418cba14a) --- .../common/__tests__/insomnia-fetch.test.ts | 110 ++++++++++++++++++ .../insomnia/src/common/insomnia-fetch.ts | 29 ++++- packages/insomnia/src/entry.main.ts | 12 +- packages/insomnia/src/main/install-plugin.ts | 36 +++--- packages/insomnia/src/main/proxy.ts | 62 +++++----- 5 files changed, 195 insertions(+), 54 deletions(-) create mode 100644 packages/insomnia/src/common/__tests__/insomnia-fetch.test.ts diff --git a/packages/insomnia/src/common/__tests__/insomnia-fetch.test.ts b/packages/insomnia/src/common/__tests__/insomnia-fetch.test.ts new file mode 100644 index 0000000000..c65555e394 --- /dev/null +++ b/packages/insomnia/src/common/__tests__/insomnia-fetch.test.ts @@ -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', + ); + }); +}); diff --git a/packages/insomnia/src/common/insomnia-fetch.ts b/packages/insomnia/src/common/insomnia-fetch.ts index 60cfeaf184..a1332e4a26 100644 --- a/packages/insomnia/src/common/insomnia-fetch.ts +++ b/packages/insomnia/src/common/insomnia-fetch.ts @@ -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; + +// 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({ method, @@ -39,7 +48,7 @@ export async function insomniaFetch({ } 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({ } return isJson ? response.json() : (response.text() as Promise); } 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; } } diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index 75be7b4683..842cb0f7c4 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -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(); diff --git a/packages/insomnia/src/main/install-plugin.ts b/packages/insomnia/src/main/install-plugin.ts index bc3cf8f2aa..b86274c811 100644 --- a/packages/insomnia/src/main/install-plugin.ts +++ b/packages/insomnia/src/main/install-plugin.ts @@ -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 diff --git a/packages/insomnia/src/main/proxy.ts b/packages/insomnia/src/main/proxy.ts index 1047833326..9e83fb1668 100644 --- a/packages/insomnia/src/main/proxy.ts +++ b/packages/insomnia/src/main/proxy.ts @@ -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; } }