diff --git a/main.ts b/main.ts index 22fc13287..f8c994b8a 100644 --- a/main.ts +++ b/main.ts @@ -15,6 +15,10 @@ setGlobalDispatcher(new Agent({ allowH2: false })); import { runCli } from '@cli'; import { recoverOrphanedSettingsBackups } from '@shared/claude-settings'; +import { routeAxiosThroughEnvProxy } from '@shared/http-proxy'; + +// axios would send https to an env proxy as plain http; tunnel it instead. +routeAxiosThroughEnvProxy(); // Heal any .claude/settings backup a previous interrupted run left orphaned, // before anything else reads Claude settings — conflict detection, OAuth, and diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts new file mode 100644 index 000000000..8de03727f --- /dev/null +++ b/src/shared/__tests__/api-errors.test.ts @@ -0,0 +1,256 @@ +import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; +import axios from 'axios'; +import { analytics } from '@utils/analytics'; +import { + diagnoseApiError, + fetchProjectData, + handleApiError, + retryTransient, +} from '../api'; + +vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); +vi.mock('@utils/analytics', () => ({ + analytics: { captureException: vi.fn() }, +})); + +const url = 'https://us.posthog.com/api/projects/1/'; +const config = { url, headers: new AxiosHeaders() }; + +function networkError(code: string, requestUrl = url): AxiosError { + const requestConfig = { url: requestUrl, headers: new AxiosHeaders() }; + return new AxiosError('request failed', code, requestConfig, {}); +} + +const localUrl = 'http://localhost:8010/api/projects/1/'; + +function httpError(status: number): AxiosError { + const response = { status, data: {}, config } as AxiosResponse; + return new AxiosError( + 'request failed', + 'ERR_BAD_RESPONSE', + config, + {}, + response, + ); +} + +describe('retryTransient', () => { + const sleep = vi.fn(() => Promise.resolve()); + + beforeEach(() => sleep.mockClear()); + + it('retries a network error until the request succeeds', async () => { + const request = vi + .fn() + .mockRejectedValueOnce(networkError('ECONNRESET')) + .mockRejectedValueOnce(httpError(502)) + .mockResolvedValue('ok'); + + await expect(retryTransient(request, sleep)).resolves.toBe('ok'); + expect(request).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('gives up after three attempts', async () => { + const request = vi.fn().mockRejectedValue(httpError(503)); + + await expect(retryTransient(request, sleep)).rejects.toBeInstanceOf( + AxiosError, + ); + expect(request).toHaveBeenCalledTimes(3); + }); + + it('does not retry a status a retry cannot fix', async () => { + const request = vi.fn().mockRejectedValue(httpError(403)); + + await expect(retryTransient(request, sleep)).rejects.toBeInstanceOf( + AxiosError, + ); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('does not retry a local server that is not answering', async () => { + const request = vi + .fn() + .mockRejectedValue(networkError('ECONNREFUSED', localUrl)); + + await expect(retryTransient(request, sleep)).rejects.toBeInstanceOf( + AxiosError, + ); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('does not retry an untrusted certificate', async () => { + const request = vi + .fn() + .mockRejectedValue(networkError('SELF_SIGNED_CERT_IN_CHAIN')); + + await expect(retryTransient(request, sleep)).rejects.toBeInstanceOf( + AxiosError, + ); + expect(request).toHaveBeenCalledTimes(1); + }); +}); + +describe('handleApiError', () => { + it('names the host and code when no response came back', () => { + const error = handleApiError( + networkError('ECONNRESET'), + 'fetch project data', + ); + + expect(error.message).toContain( + "Couldn't reach us.posthog.com to fetch project data (ECONNRESET)", + ); + expect(error.statusCode).toBeUndefined(); + }); + + it('points at TLS inspection for an untrusted certificate', () => { + const error = handleApiError( + networkError('SELF_SIGNED_CERT_IN_CHAIN'), + 'fetch project data', + ); + + expect(error.message).toContain('intercepting HTTPS'); + expect(error.message).toContain('SELF_SIGNED_CERT_IN_CHAIN'); + }); + + it('points at the local server instead of the network for localhost', () => { + const error = handleApiError( + networkError('ECONNREFUSED', localUrl), + 'fetch project data', + ); + + expect(error.message).toBe( + "Couldn't reach localhost:8010 to fetch project data (ECONNREFUSED). Check that your local server is running.", + ); + }); + + it('includes the status of an unexpected response', () => { + const error = handleApiError(httpError(502), 'fetch project data'); + + expect(error.message).toBe('Failed to fetch project data (HTTP 502)'); + expect(error.statusCode).toBe(502); + }); +}); + +describe('diagnoseApiError', () => { + const certError = networkError('SELF_SIGNED_CERT_IN_CHAIN'); + + it.each([true, false])( + 'advises IT and records the check when the system store trusts the chain: %s', + async (systemStoreTrusts) => { + const check = vi.fn().mockResolvedValue({ + issuer: 'Example Corp Root CA', + systemStoreTrusts, + }); + + const error = await diagnoseApiError( + certError, + 'fetch project data', + check, + () => false, + ); + + expect(check).toHaveBeenCalledWith('us.posthog.com', 443); + expect(error.message).toContain('Ask your IT team'); + expect(error.message).not.toContain('use-system-ca'); + expect(error.message).not.toContain('Example Corp'); + expect(error.systemStoreTrusts).toBe(systemStoreTrusts); + }, + ); + + it('checks the request port on a host with one', async () => { + const check = vi.fn().mockResolvedValue(null); + + await diagnoseApiError( + networkError( + 'SELF_SIGNED_CERT_IN_CHAIN', + 'https://example.com:8443/api/', + ), + 'fetch project data', + check, + () => false, + ); + + expect(check).toHaveBeenCalledWith('example.com', 8443); + }); + + it('skips the check when an https proxy is set', async () => { + const check = vi.fn(); + + const error = await diagnoseApiError( + certError, + 'fetch project data', + check, + () => true, + ); + + expect(check).not.toHaveBeenCalled(); + expect(error.message).toContain('Ask your IT team'); + expect(error.systemStoreTrusts).toBeUndefined(); + }); + + it('skips the check for a local server', async () => { + const check = vi.fn(); + + await diagnoseApiError( + networkError('SELF_SIGNED_CERT_IN_CHAIN', 'https://localhost:8010/'), + 'fetch project data', + check, + () => false, + ); + + expect(check).not.toHaveBeenCalled(); + }); + + it('skips the check for any other failure', async () => { + const check = vi.fn(); + + const error = await diagnoseApiError( + networkError('ECONNRESET'), + 'fetch project data', + check, + () => false, + ); + + expect(check).not.toHaveBeenCalled(); + expect(error.message).toContain("Couldn't reach us.posthog.com"); + }); +}); + +describe('fetchProjectData failure analytics', () => { + afterEach(() => vi.restoreAllMocks()); + + it('captures the network code', async () => { + // A local host isn't retried, so the test needs no timers. + vi.spyOn(axios, 'get').mockRejectedValue( + networkError('ECONNREFUSED', localUrl), + ); + + await expect( + fetchProjectData('token', 1, 'http://localhost:8010'), + ).rejects.toThrow('ECONNREFUSED'); + + expect(analytics.captureException).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + network_code: 'ECONNREFUSED', + status_code: undefined, + }), + ); + }); + + it('captures the status code', async () => { + vi.spyOn(axios, 'get').mockRejectedValue(httpError(403)); + + await expect( + fetchProjectData('token', 1, 'https://us.posthog.com'), + ).rejects.toThrow('Access denied'); + + expect(analytics.captureException).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status_code: 403, network_code: undefined }), + ); + }); +}); diff --git a/src/shared/__tests__/http-proxy.test.ts b/src/shared/__tests__/http-proxy.test.ts new file mode 100644 index 000000000..a19dc6a97 --- /dev/null +++ b/src/shared/__tests__/http-proxy.test.ts @@ -0,0 +1,148 @@ +import type axios from 'axios'; +import { analytics } from '@utils/analytics'; +import { + httpsProxyConfigured, + redactErrorCredentials, + redactUrlCredentials, + routeAxiosThroughEnvProxy, +} from '../http-proxy'; + +vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); +vi.mock('@utils/analytics', () => ({ analytics: { setTag: vi.fn() } })); + +const proxyUrl = 'http://proxy.internal:3128'; + +type Client = Pick; + +function fakeClient(): Client { + return { + defaults: {}, + interceptors: { response: { use: vi.fn() } }, + } as unknown as Client; +} + +describe('routeAxiosThroughEnvProxy', () => { + beforeEach(() => vi.mocked(analytics.setTag).mockClear()); + + it('never tags the proxy URL', () => { + routeAxiosThroughEnvProxy( + { HTTPS_PROXY: proxyUrl }, + 'v22.22.0', + fakeClient(), + ); + + expect( + JSON.stringify(vi.mocked(analytics.setTag).mock.calls), + ).not.toContain('proxy.internal'); + }); + + it('leaves axios alone with no proxy in the environment', () => { + const client = fakeClient(); + + expect(routeAxiosThroughEnvProxy({}, 'v22.22.0', client)).toBe('none'); + expect(client.defaults).toEqual({}); + expect(analytics.setTag).toHaveBeenCalledWith('env_proxy', 'none'); + }); + + it.each(['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'npm_config_proxy'])( + 'routes axios through Node agents for %s', + (name) => { + const client = fakeClient(); + + expect( + routeAxiosThroughEnvProxy({ [name]: proxyUrl }, 'v22.22.0', client), + ).toBe('tunnel'); + expect(analytics.setTag).toHaveBeenCalledWith('env_proxy', 'tunnel'); + expect(client.defaults.proxy).toBe(false); + expect(client.defaults.httpsAgent).toBeDefined(); + expect(client.defaults.httpAgent).toBeDefined(); + }, + ); + + it.each(['v22.22.0', 'v24.5.0', 'v25.0.0', 'v26.1.0'])( + 'applies on Node %s', + (version) => { + expect( + routeAxiosThroughEnvProxy( + { HTTPS_PROXY: proxyUrl }, + version, + fakeClient(), + ), + ).toBe('tunnel'); + }, + ); + + it.each(['v23.11.0', 'v24.4.0'])( + 'keeps axios handling on Node %s, which lacks proxyEnv', + (version) => { + const client = fakeClient(); + + expect( + routeAxiosThroughEnvProxy({ HTTPS_PROXY: proxyUrl }, version, client), + ).toBe('unsupported_node'); + expect(client.defaults).toEqual({}); + expect(analytics.setTag).toHaveBeenCalledWith( + 'env_proxy', + 'unsupported_node', + ); + }, + ); +}); + +describe('httpsProxyConfigured', () => { + it.each(['HTTPS_PROXY', 'https_proxy', 'ALL_PROXY', 'npm_config_proxy'])( + 'sees %s', + (name) => { + expect( + httpsProxyConfigured({ [name]: 'http://proxy.internal:3128' }), + ).toBe(true); + }, + ); + + it('ignores an http-only or empty proxy', () => { + expect( + httpsProxyConfigured({ + HTTP_PROXY: 'http://proxy:3128', + HTTPS_PROXY: '', + }), + ).toBe(false); + }); +}); + +describe('proxy credential redaction', () => { + const tunnelMessage = + 'Failed to establish tunnel to us.posthog.com:443 via http://alice:s3cret@proxy.internal:3128: HTTP/1.1 407 Proxy Authentication Required'; + + it('removes userinfo from a URL in text', () => { + expect(redactUrlCredentials(tunnelMessage)).toBe( + 'Failed to establish tunnel to us.posthog.com:443 via http://***@proxy.internal:3128: HTTP/1.1 407 Proxy Authentication Required', + ); + }); + + it('leaves text without credentials alone', () => { + const text = 'GET https://us.posthog.com/api/projects/1/ failed'; + expect(redactUrlCredentials(text)).toBe(text); + }); + + it('scrubs the message, stack and cause chain', () => { + const cause = new Error(tunnelMessage); + const error = new Error(`request failed: ${tunnelMessage}`, { cause }); + + redactErrorCredentials(error); + + for (const e of [error, cause]) { + expect(e.message).not.toContain('s3cret'); + expect(e.stack).not.toContain('s3cret'); + } + }); + + it('installs the scrubber only when tunnelling', () => { + const tunnelled = fakeClient(); + routeAxiosThroughEnvProxy({ HTTPS_PROXY: proxyUrl }, 'v22.22.0', tunnelled); + expect(tunnelled.interceptors.response.use).toHaveBeenCalledTimes(1); + + const direct = fakeClient(); + routeAxiosThroughEnvProxy({}, 'v22.22.0', direct); + expect(direct.interceptors.response.use).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/__tests__/tls-check.test.ts b/src/shared/__tests__/tls-check.test.ts new file mode 100644 index 000000000..395d61d00 --- /dev/null +++ b/src/shared/__tests__/tls-check.test.ts @@ -0,0 +1,99 @@ +import { EventEmitter } from 'node:events'; +import type tls from 'node:tls'; +import { checkCertificateChain } from '../tls-check'; + +type FakeSocket = EventEmitter & { + destroy: ReturnType; + getPeerCertificate: () => unknown; +}; + +function fakeConnect(socket: FakeSocket) { + return vi.fn(() => socket) as unknown as typeof tls.connect; +} + +function fakeSocket(): FakeSocket { + const root = { + fingerprint256: 'root', + issuer: { O: 'Example Corp Root CA' }, + } as Record; + root.issuerCertificate = root; + const leaf = { + fingerprint256: 'leaf', + issuer: { CN: 'Example Corp Intermediate' }, + issuerCertificate: root, + }; + return Object.assign(new EventEmitter(), { + destroy: vi.fn(), + getPeerCertificate: () => leaf, + }); +} + +function errorWithCode(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }); +} + +const store = { + getCACertificates: (type: 'default' | 'system') => + type === 'system' ? ['bundled', 'keychain'] : ['bundled'], +}; + +describe('checkCertificateChain', () => { + it('reports the top issuer when the system store verifies the chain', async () => { + const socket = fakeSocket(); + const connect = fakeConnect(socket); + + const result = checkCertificateChain('us.posthog.com', 443, connect, store); + socket.emit('secureConnect'); + + await expect(result).resolves.toEqual({ + issuer: 'Example Corp Root CA', + systemStoreTrusts: true, + }); + expect(connect).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'us.posthog.com', + port: 443, + ca: ['bundled', 'keychain'], + rejectUnauthorized: true, + }), + ); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('reports untrusted when the chain fails verification', async () => { + const socket = fakeSocket(); + + const result = checkCertificateChain( + 'us.posthog.com', + 443, + fakeConnect(socket), + store, + ); + socket.emit('error', errorWithCode('SELF_SIGNED_CERT_IN_CHAIN')); + + await expect(result).resolves.toEqual({ systemStoreTrusts: false }); + }); + + it('resolves null when the handshake fails for another reason', async () => { + const socket = fakeSocket(); + + const result = checkCertificateChain( + 'us.posthog.com', + 443, + fakeConnect(socket), + store, + ); + socket.emit('error', errorWithCode('ECONNRESET')); + + await expect(result).resolves.toBeNull(); + }); + + it('skips the connection when the OS store cannot be read', async () => { + const connect = vi.fn() as unknown as typeof tls.connect; + + await expect( + checkCertificateChain('us.posthog.com', 443, connect, {}), + ).resolves.toBeNull(); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/api.ts b/src/shared/api.ts index 18920ef77..ce9eb4e35 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,6 +1,9 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; +import { logToFile } from '@utils/debug'; +import { httpsProxyConfigured } from './http-proxy'; +import { checkCertificateChain, UNTRUSTED_CERT_CODES } from './tls-check'; import { WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; @@ -162,30 +165,93 @@ export class ApiError extends Error { message: string, public readonly statusCode?: number, public readonly endpoint?: string, + /** Set after a TLS check: whether the OS store would verify the host's chain. */ + public readonly systemStoreTrusts?: boolean, ) { super(message); this.name = 'ApiError'; } } +function isUntrustedCertError(error: unknown): error is AxiosError { + return ( + axios.isAxiosError(error) && + !error.response && + UNTRUSTED_CERT_CODES.has(error.code ?? '') + ); +} + +const RETRY_DELAYS_MS = [500, 1500]; + +/** No response (a network error), 5xx, 408 and 429 can heal on a retry; an untrusted certificate can't. */ +function isTransient(error: unknown): boolean { + if (!axios.isAxiosError(error) || isUntrustedCertError(error)) return false; + const status = error.response?.status; + // A local server that isn't answering won't start in the next two seconds. + if (status === undefined) return !isLocalHost(error); + return status >= 500 || status === 408 || status === 429; +} + +/** Runs a login-path request, retrying transient failures with backoff. */ +export async function retryTransient( + request: () => Promise, + sleep: (ms: number) => Promise = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)), +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await request(); + } catch (error) { + const delay = RETRY_DELAYS_MS[attempt]; + if (delay === undefined || !isTransient(error)) throw error; + logToFile(`[api] ${describeAxiosFailure(error)}, retrying in ${delay}ms`); + await sleep(delay); + } + } +} + +function describeAxiosFailure(error: unknown): string { + if (!axios.isAxiosError(error)) return String(error); + const status = error.response?.status; + return `${error.config?.url ?? 'request'} failed: ${ + status ? `HTTP ${status}` : error.code ?? error.message + }`; +} + +/** Why a fetch failed, as exception properties a query can filter on. */ +function failureProperties( + error: unknown, + apiError: ApiError, +): Record { + return { + status_code: apiError.statusCode, + network_code: + axios.isAxiosError(error) && !error.response ? error.code : undefined, + system_store_trusts: apiError.systemStoreTrusts, + }; +} + export async function fetchUserData( accessToken: string, baseUrl: string, ): Promise { try { - const response = await axios.get(`${baseUrl}/api/users/@me/`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'User-Agent': WIZARD_USER_AGENT, - }, - }); + const response = await retryTransient(() => + axios.get(`${baseUrl}/api/users/@me/`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': WIZARD_USER_AGENT, + }, + }), + ); return ApiUserSchema.parse(response.data); } catch (error) { - const apiError = handleApiError(error, 'fetch user data'); + const apiError = await diagnoseApiError(error, 'fetch user data'); analytics.captureException(apiError, { endpoint: '/api/users/@me/', baseUrl, + ...failureProperties(error, apiError), }); throw apiError; } @@ -240,20 +306,23 @@ export async function fetchProjectData( baseUrl: string, ): Promise { try { - const response = await axios.get(`${baseUrl}/api/projects/${projectId}/`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'User-Agent': WIZARD_USER_AGENT, - }, - }); + const response = await retryTransient(() => + axios.get(`${baseUrl}/api/projects/${projectId}/`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': WIZARD_USER_AGENT, + }, + }), + ); return ApiProjectSchema.parse(response.data); } catch (error) { - const apiError = handleApiError(error, 'fetch project data'); + const apiError = await diagnoseApiError(error, 'fetch project data'); analytics.captureException(apiError, { endpoint: `/api/projects/${projectId}/`, baseUrl, projectId, + ...failureProperties(error, apiError), }); throw apiError; } @@ -422,6 +491,102 @@ export async function fetchMarkdownNotebook( } } +function hostOf(error: AxiosError): string | undefined { + try { + return error.config?.url ? new URL(error.config.url).host : undefined; + } catch { + return undefined; + } +} + +/** The request's hostname and port, apart, for a raw TLS connection. */ +function checkTarget( + error: AxiosError, +): { hostname: string; port: number } | undefined { + try { + const url = new URL(error.config?.url ?? ''); + if (url.protocol !== 'https:') return undefined; + return { hostname: url.hostname, port: Number(url.port) || 443 }; + } catch { + return undefined; + } +} + +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** A request to this machine, such as a local PostHog in dev. */ +function isLocalHost(error: AxiosError): boolean { + try { + return LOCAL_HOSTNAMES.has(new URL(error.config?.url ?? '').hostname); + } catch { + return false; + } +} + +function untrustedCertMessage( + host: string, + code: string, + operation: string, +): string { + return `Couldn't ${operation}: a VPN, proxy or security tool on this network is intercepting HTTPS to ${host} (${code}). Ask your IT team to allow *.posthog.com without HTTPS inspection, then run the wizard again.`; +} + +/** + * `handleApiError`, plus a handshake-only TLS check when the chain didn't + * verify. The check only diagnoses; the issuer goes to the local log alone, + * since it can name the user's company. + */ +export async function diagnoseApiError( + error: unknown, + operation: string, + check: typeof checkCertificateChain = checkCertificateChain, + proxyConfigured: () => boolean = httpsProxyConfigured, +): Promise { + if (!isUntrustedCertError(error) || isLocalHost(error)) { + return handleApiError(error, operation); + } + const host = hostOf(error); + const target = checkTarget(error); + // A direct check would bypass the proxy the request went through and see a different chain. + const viaProxy = proxyConfigured(); + if (viaProxy) { + logToFile( + '[tls] an https proxy is set in the environment; chain check skipped', + ); + } + const result = + target && !viaProxy ? await check(target.hostname, target.port) : null; + if (host && result) { + logToFile( + `[tls] ${host} chain signed by "${ + result.issuer ?? 'unknown' + }"; trusted by the system store: ${String(result.systemStoreTrusts)}`, + ); + } + return new ApiError( + untrustedCertMessage( + host ?? 'PostHog', + error.code ?? 'untrusted certificate', + operation, + ), + undefined, + error.config?.url, + result?.systemStoreTrusts, + ); +} + +function unreachableMessage(error: AxiosError, operation: string): string { + const host = hostOf(error) ?? 'PostHog'; + const code = error.code ?? 'no response'; + if (isLocalHost(error)) { + return `Couldn't reach ${host} to ${operation} (${code}). Check that your local server is running.`; + } + if (UNTRUSTED_CERT_CODES.has(code)) { + return untrustedCertMessage(host, code, operation); + } + return `Couldn't reach ${host} to ${operation} (${code}). A VPN, proxy, firewall or security tool may be blocking it. Allow *.posthog.com, then run the wizard again.`; +} + export function handleApiError(error: unknown, operation: string): ApiError { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ detail?: string }>; @@ -429,6 +594,14 @@ export function handleApiError(error: unknown, operation: string): ApiError { const detail = axiosError.response?.data?.detail; const endpoint = axiosError.config?.url; + if (!axiosError.response) { + return new ApiError( + unreachableMessage(axiosError, operation), + undefined, + endpoint, + ); + } + if (status === 401) { return new ApiError( `Authentication failed while trying to ${operation}`, @@ -453,7 +626,8 @@ export function handleApiError(error: unknown, operation: string): ApiError { ); } - const message = detail || `Failed to ${operation}`; + const message = + detail || `Failed to ${operation} (HTTP ${axiosError.response.status})`; return new ApiError(message, status, endpoint); } diff --git a/src/shared/http-proxy.ts b/src/shared/http-proxy.ts new file mode 100644 index 000000000..b03d1fa71 --- /dev/null +++ b/src/shared/http-proxy.ts @@ -0,0 +1,135 @@ +import http from 'node:http'; +import https from 'node:https'; +import axios from 'axios'; +import { satisfies } from 'semver'; +import { analytics } from '@utils/analytics'; +import { logToFile } from '@utils/debug'; + +/** Node lines whose `http(s).Agent` takes `proxyEnv` and tunnels https with CONNECT. */ +const PROXY_ENV_NODE_RANGE = '^22.22.0 || >=24.5.0'; + +/** The variables axios reads (through proxy-from-env) for an https proxy, in its order. */ +export const HTTPS_PROXY_VARS = [ + 'npm_config_https_proxy', + 'https_proxy', + 'HTTPS_PROXY', + 'npm_config_proxy', + 'all_proxy', + 'ALL_PROXY', +]; + +const HTTP_PROXY_VARS = [ + 'npm_config_http_proxy', + 'http_proxy', + 'HTTP_PROXY', + 'npm_config_proxy', + 'all_proxy', + 'ALL_PROXY', +]; + +const NO_PROXY_VARS = ['npm_config_no_proxy', 'no_proxy', 'NO_PROXY']; + +function firstSet(env: NodeJS.ProcessEnv, names: string[]): string | undefined { + for (const name of names) { + if (env[name]) return env[name]; + } + return undefined; +} + +/** Whether the environment names an https proxy. */ +export function httpsProxyConfigured( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return firstSet(env, HTTPS_PROXY_VARS) !== undefined; +} + +type ProxyAgentOptions = { proxyEnv: Record }; + +type ProxyClient = Pick; + +/** `scheme://user:pass@` — the userinfo of a URL in free text. */ +const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi; + +/** Removes URL credentials from text, such as a proxy URL in Node's tunnel errors. */ +export function redactUrlCredentials(text: string): string { + return text.replace(URL_USERINFO, '$1***@'); +} + +/** + * Node tunnels from the agent's internal proxy config; the copy left in its + * public `options` would put the proxy URL into `JSON.stringify` of an axios + * error, whose `config` holds the agent. + */ +function withoutPublicProxyEnv(agent: T): T { + delete (agent as unknown as { options: Partial }).options + .proxyEnv; + return agent; +} + +/** Scrubs an error, and each error in its `cause` chain, in place. */ +export function redactErrorCredentials(error: unknown, depth = 0): void { + if (!(error instanceof Error) || depth > 5) return; + error.message = redactUrlCredentials(error.message); + if (error.stack) error.stack = redactUrlCredentials(error.stack); + redactErrorCredentials(error.cause, depth + 1); +} + +/** How axios reaches the network, tagged on every event as `env_proxy`. */ +export type EnvProxyMode = 'none' | 'tunnel' | 'unsupported_node'; + +/** + * axios sends an https request to an http proxy as plain http, bearer token + * included. When the environment names a proxy, hand axios Node's agents, + * which tunnel with CONNECT. Node without `proxyEnv` keeps axios's handling, + * so the request still reaches the proxy. Never logs or tags the proxy URL; + * it can carry credentials. + */ +export function routeAxiosThroughEnvProxy( + env: NodeJS.ProcessEnv = process.env, + nodeVersion: string = process.version, + client: ProxyClient = axios, +): EnvProxyMode { + const mode = applyEnvProxy(env, nodeVersion, client); + analytics.setTag('env_proxy', mode); + return mode; +} + +function applyEnvProxy( + env: NodeJS.ProcessEnv, + nodeVersion: string, + client: ProxyClient, +): EnvProxyMode { + const httpsProxy = firstSet(env, HTTPS_PROXY_VARS); + const httpProxy = firstSet(env, HTTP_PROXY_VARS); + if (!httpsProxy && !httpProxy) return 'none'; + if (!satisfies(nodeVersion, PROXY_ENV_NODE_RANGE)) { + logToFile( + `[proxy] Node ${nodeVersion} can't tunnel through the environment's proxy; axios keeps its own handling`, + ); + return 'unsupported_node'; + } + + const proxyEnv: Record = {}; + if (httpsProxy) proxyEnv.HTTPS_PROXY = httpsProxy; + if (httpProxy) proxyEnv.HTTP_PROXY = httpProxy; + const noProxy = firstSet(env, NO_PROXY_VARS); + if (noProxy) proxyEnv.NO_PROXY = noProxy; + + // The repo's `@types/node` predates `proxyEnv`. + const options: ProxyAgentOptions = { proxyEnv }; + client.defaults.proxy = false; + // Node's ERR_PROXY_TUNNEL message names the proxy URL, credentials included; + // scrub it before any caller logs or captures the error. + client.interceptors.response.use(undefined, (error: unknown) => { + redactErrorCredentials(error); + return Promise.reject(error); + }); + client.defaults.httpsAgent = withoutPublicProxyEnv( + new https.Agent(options as unknown as https.AgentOptions), + ); + client.defaults.httpAgent = withoutPublicProxyEnv( + new http.Agent(options as unknown as http.AgentOptions), + ); + logToFile("[proxy] axios tunnels through the environment's proxy"); + return 'tunnel'; +} diff --git a/src/shared/tls-check.ts b/src/shared/tls-check.ts new file mode 100644 index 000000000..bbc99e5c5 --- /dev/null +++ b/src/shared/tls-check.ts @@ -0,0 +1,95 @@ +import tls, { type DetailedPeerCertificate } from 'node:tls'; + +/** What a handshake-only connection learned about a host's certificate chain. */ +export type ChainCheck = { + /** Organization (or common name) of the topmost certificate's issuer; known only when the chain verified. */ + issuer?: string; + /** Whether Node's defaults plus the OS store verify the chain, as `--use-system-ca` would. */ + systemStoreTrusts: boolean; +}; + +/** Node's codes for a certificate chain it can't verify, as TLS inspection causes. */ +export const UNTRUSTED_CERT_CODES = new Set([ + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'CERT_UNTRUSTED', +]); + +/** Node 22.15+ TLS API that the repo's `@types/node` predates. */ +type CaStore = { getCACertificates?(type: 'default' | 'system'): string[] }; + +const CHECK_TIMEOUT_MS = 5000; + +function systemTrustedCAs(store: CaStore): string[] | undefined { + try { + if (!store.getCACertificates) return undefined; + const defaults = store.getCACertificates('default'); + const known = new Set(defaults); + return [ + ...defaults, + ...store.getCACertificates('system').filter((c) => !known.has(c)), + ]; + } catch { + return undefined; + } +} + +function topIssuer(leaf: DetailedPeerCertificate): string | undefined { + let cert = leaf; + // A self-signed root points at itself. + while ( + cert.issuerCertificate && + cert.issuerCertificate.fingerprint256 !== cert.fingerprint256 + ) { + cert = cert.issuerCertificate; + } + return cert.issuer?.O || cert.issuer?.CN || undefined; +} + +/** + * Completes one verified TLS handshake with `host` against Node's defaults plus + * the OS store, sends nothing over it and closes. The OS store is passed only to + * this connection, so the process's own trust is unchanged. Null when the store + * can't be read or the handshake fails for a reason other than the chain. + */ +export function checkCertificateChain( + host: string, + port: number, + connect: typeof tls.connect = tls.connect, + store: CaStore = tls as unknown as CaStore, +): Promise { + const ca = systemTrustedCAs(store); + // Without the OS store this would repeat the check that already failed. + if (!ca) return Promise.resolve(null); + return new Promise((resolve) => { + const socket = connect({ + host, + port, + servername: host, + ca, + rejectUnauthorized: true, + timeout: CHECK_TIMEOUT_MS, + }); + const finish = (result: ChainCheck | null) => { + socket.destroy(); + resolve(result); + }; + socket.once('secureConnect', () => + finish({ + issuer: topIssuer(socket.getPeerCertificate(true)), + systemStoreTrusts: true, + }), + ); + socket.once('timeout', () => finish(null)); + socket.once('error', (error: NodeJS.ErrnoException) => + finish( + UNTRUSTED_CERT_CODES.has(error.code ?? '') + ? { systemStoreTrusts: false } + : null, + ), + ); + }); +}