From 17c842228331540bbc24cbde88f276d0c686f97f Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 12:58:42 -0700 Subject: [PATCH 1/6] check tls, api, certs --- src/shared/__tests__/api-errors.test.ts | 139 ++++++++++++++++++++ src/shared/__tests__/tls-probe.test.ts | 87 +++++++++++++ src/shared/api.ts | 165 +++++++++++++++++++++--- src/shared/tls-probe.ts | 76 +++++++++++ 4 files changed, 452 insertions(+), 15 deletions(-) create mode 100644 src/shared/__tests__/api-errors.test.ts create mode 100644 src/shared/__tests__/tls-probe.test.ts create mode 100644 src/shared/tls-probe.ts diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts new file mode 100644 index 000000000..f3adac9bb --- /dev/null +++ b/src/shared/__tests__/api-errors.test.ts @@ -0,0 +1,139 @@ +import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; +import { diagnoseApiError, handleApiError, retryTransient } from '../api'; + +vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); + +const url = 'https://us.posthog.com/api/projects/1/'; +const config = { url, headers: new AxiosHeaders() }; + +function networkError(code: string): AxiosError { + return new AxiosError('request failed', code, config, {}); +} + +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 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('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 probe when the system store trusts the chain: %s', + async (systemStoreTrusts) => { + const probe = vi.fn().mockResolvedValue({ + issuer: 'Example Corp Root CA', + systemStoreTrusts, + }); + + const error = await diagnoseApiError( + certError, + 'fetch project data', + probe, + ); + + expect(probe).toHaveBeenCalledWith('us.posthog.com'); + 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('skips the probe for any other failure', async () => { + const probe = vi.fn(); + + const error = await diagnoseApiError( + networkError('ECONNRESET'), + 'fetch project data', + probe, + ); + + expect(probe).not.toHaveBeenCalled(); + expect(error.message).toContain("Couldn't reach us.posthog.com"); + }); +}); diff --git a/src/shared/__tests__/tls-probe.test.ts b/src/shared/__tests__/tls-probe.test.ts new file mode 100644 index 000000000..46dd0a996 --- /dev/null +++ b/src/shared/__tests__/tls-probe.test.ts @@ -0,0 +1,87 @@ +import { EventEmitter } from 'node:events'; +import type tls from 'node:tls'; +import { probeCertificateChain } from '../tls-probe'; + +type FakeSocket = EventEmitter & { + authorized: boolean; + destroy: ReturnType; + getPeerCertificate: () => unknown; +}; + +function fakeConnect(socket: FakeSocket) { + return vi.fn(() => socket) as unknown as typeof tls.connect; +} + +function fakeSocket(authorized: boolean): 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(), { + authorized, + destroy: vi.fn(), + getPeerCertificate: () => leaf, + }); +} + +const store = { + getCACertificates: (type: 'default' | 'system') => + type === 'system' ? ['bundled', 'keychain'] : ['bundled'], +}; + +describe('probeCertificateChain', () => { + it('reports the top issuer and whether the system store verifies it', async () => { + const socket = fakeSocket(true); + const connect = fakeConnect(socket); + + const result = probeCertificateChain('us.posthog.com', 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', + ca: ['bundled', 'keychain'], + }), + ); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('leaves systemStoreTrusts unknown when the OS store cannot be read', async () => { + const socket = fakeSocket(false); + + const result = probeCertificateChain( + 'us.posthog.com', + fakeConnect(socket), + {}, + ); + socket.emit('secureConnect'); + + await expect(result).resolves.toEqual({ + issuer: 'Example Corp Root CA', + systemStoreTrusts: undefined, + }); + }); + + it('resolves null when the handshake fails', async () => { + const socket = fakeSocket(false); + + const result = probeCertificateChain( + 'us.posthog.com', + fakeConnect(socket), + store, + ); + socket.emit('error', new Error('ECONNRESET')); + + await expect(result).resolves.toBeNull(); + }); +}); diff --git a/src/shared/api.ts b/src/shared/api.ts index 18920ef77..410a38d41 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,6 +1,8 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; +import { logToFile } from '@utils/debug'; +import { probeCertificateChain } from './tls-probe'; import { WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; @@ -162,30 +164,90 @@ export class ApiError extends Error { message: string, public readonly statusCode?: number, public readonly endpoint?: string, + /** Set after a TLS probe: whether the OS store would verify the host's chain. */ + public readonly systemStoreTrusts?: boolean, ) { super(message); this.name = 'ApiError'; } } +/** Node's codes for a certificate chain it can't verify, as TLS inspection causes. */ +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', +]); + +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; + return ( + status === undefined || 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 + }`; +} + 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, + system_store_trusts: apiError.systemStoreTrusts, }); throw apiError; } @@ -240,20 +302,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, + system_store_trusts: apiError.systemStoreTrusts, }); throw apiError; } @@ -422,6 +487,67 @@ 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; + } +} + +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 probe when the chain didn't + * verify. The probe 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, + probe: typeof probeCertificateChain = probeCertificateChain, +): Promise { + if (!isUntrustedCertError(error)) return handleApiError(error, operation); + const host = hostOf(error); + const result = host ? await probe(host) : null; + if (host && result) { + const trusted = + result.systemStoreTrusts === undefined + ? 'unknown' + : String(result.systemStoreTrusts); + logToFile( + `[tls] ${host} chain signed by "${ + result.issuer ?? 'unknown' + }"; trusted by the system store: ${trusted}`, + ); + } + 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 (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 +555,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 +587,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/tls-probe.ts b/src/shared/tls-probe.ts new file mode 100644 index 000000000..59fd78085 --- /dev/null +++ b/src/shared/tls-probe.ts @@ -0,0 +1,76 @@ +import tls, { type DetailedPeerCertificate } from 'node:tls'; + +/** What a handshake-only connection learned about a host's certificate chain. */ +export type ChainProbe = { + /** Organization (or common name) of the topmost certificate's issuer. */ + issuer?: string; + /** Whether Node's defaults plus the OS store verify the chain, as `--use-system-ca` would. */ + systemStoreTrusts?: boolean; +}; + +/** Node 22.15+ TLS API that the repo's `@types/node` predates. */ +type CaStore = { getCACertificates?(type: 'default' | 'system'): string[] }; + +const PROBE_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 TLS handshake with `host`, 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 handshake itself fails. + */ +export function probeCertificateChain( + host: string, + connect: typeof tls.connect = tls.connect, + store: CaStore = tls as unknown as CaStore, +): Promise { + const ca = systemTrustedCAs(store); + return new Promise((resolve) => { + const socket = connect({ + host, + port: 443, + servername: host, + ca, + // Read the chain rather than reject it; no request follows. + rejectUnauthorized: false, + timeout: PROBE_TIMEOUT_MS, + }); + const finish = (result: ChainProbe | null) => { + socket.destroy(); + resolve(result); + }; + socket.once('secureConnect', () => + finish({ + issuer: topIssuer(socket.getPeerCertificate(true)), + systemStoreTrusts: ca ? socket.authorized : undefined, + }), + ); + socket.once('timeout', () => finish(null)); + socket.once('error', () => finish(null)); + }); +} From cce9432557d21afd5ea2ad346699e97073528df2 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 12:59:26 -0700 Subject: [PATCH 2/6] local --- src/shared/__tests__/api-errors.test.ts | 41 +++++++++++++++++++++++-- src/shared/api.ts | 24 ++++++++++++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts index f3adac9bb..3a6a4383d 100644 --- a/src/shared/__tests__/api-errors.test.ts +++ b/src/shared/__tests__/api-errors.test.ts @@ -6,10 +6,13 @@ vi.mock('@utils/debug', () => ({ logToFile: vi.fn() })); const url = 'https://us.posthog.com/api/projects/1/'; const config = { url, headers: new AxiosHeaders() }; -function networkError(code: string): AxiosError { - return new AxiosError('request failed', code, config, {}); +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( @@ -56,6 +59,17 @@ describe('retryTransient', () => { 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() @@ -91,6 +105,17 @@ describe('handleApiError', () => { 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'); @@ -124,6 +149,18 @@ describe('diagnoseApiError', () => { }, ); + it('skips the probe for a local server', async () => { + const probe = vi.fn(); + + await diagnoseApiError( + networkError('SELF_SIGNED_CERT_IN_CHAIN', 'https://localhost:8010/'), + 'fetch project data', + probe, + ); + + expect(probe).not.toHaveBeenCalled(); + }); + it('skips the probe for any other failure', async () => { const probe = vi.fn(); diff --git a/src/shared/api.ts b/src/shared/api.ts index 410a38d41..e48d62c73 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -196,9 +196,9 @@ const RETRY_DELAYS_MS = [500, 1500]; function isTransient(error: unknown): boolean { if (!axios.isAxiosError(error) || isUntrustedCertError(error)) return false; const status = error.response?.status; - return ( - status === undefined || status >= 500 || status === 408 || status === 429 - ); + // 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. */ @@ -495,6 +495,17 @@ function hostOf(error: AxiosError): string | 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, @@ -513,7 +524,9 @@ export async function diagnoseApiError( operation: string, probe: typeof probeCertificateChain = probeCertificateChain, ): Promise { - if (!isUntrustedCertError(error)) return handleApiError(error, operation); + if (!isUntrustedCertError(error) || isLocalHost(error)) { + return handleApiError(error, operation); + } const host = hostOf(error); const result = host ? await probe(host) : null; if (host && result) { @@ -542,6 +555,9 @@ export async function diagnoseApiError( 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); } From 0240ca93ef54944b9bb7a961dfdd334db1387e94 Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 13:06:05 -0700 Subject: [PATCH 3/6] bot review --- src/shared/__tests__/tls-probe.test.ts | 41 ++++++++++++++++---------- src/shared/api.ts | 18 ++--------- src/shared/tls-probe.ts | 36 ++++++++++++++++------ 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/src/shared/__tests__/tls-probe.test.ts b/src/shared/__tests__/tls-probe.test.ts index 46dd0a996..d95629c7f 100644 --- a/src/shared/__tests__/tls-probe.test.ts +++ b/src/shared/__tests__/tls-probe.test.ts @@ -3,7 +3,6 @@ import type tls from 'node:tls'; import { probeCertificateChain } from '../tls-probe'; type FakeSocket = EventEmitter & { - authorized: boolean; destroy: ReturnType; getPeerCertificate: () => unknown; }; @@ -12,7 +11,7 @@ function fakeConnect(socket: FakeSocket) { return vi.fn(() => socket) as unknown as typeof tls.connect; } -function fakeSocket(authorized: boolean): FakeSocket { +function fakeSocket(): FakeSocket { const root = { fingerprint256: 'root', issuer: { O: 'Example Corp Root CA' }, @@ -24,20 +23,23 @@ function fakeSocket(authorized: boolean): FakeSocket { issuerCertificate: root, }; return Object.assign(new EventEmitter(), { - authorized, 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('probeCertificateChain', () => { - it('reports the top issuer and whether the system store verifies it', async () => { - const socket = fakeSocket(true); + it('reports the top issuer when the system store verifies the chain', async () => { + const socket = fakeSocket(); const connect = fakeConnect(socket); const result = probeCertificateChain('us.posthog.com', connect, store); @@ -51,37 +53,44 @@ describe('probeCertificateChain', () => { expect.objectContaining({ host: 'us.posthog.com', ca: ['bundled', 'keychain'], + rejectUnauthorized: true, }), ); expect(socket.destroy).toHaveBeenCalled(); }); - it('leaves systemStoreTrusts unknown when the OS store cannot be read', async () => { - const socket = fakeSocket(false); + it('reports untrusted when the chain fails verification', async () => { + const socket = fakeSocket(); const result = probeCertificateChain( 'us.posthog.com', fakeConnect(socket), - {}, + store, ); - socket.emit('secureConnect'); + socket.emit('error', errorWithCode('SELF_SIGNED_CERT_IN_CHAIN')); - await expect(result).resolves.toEqual({ - issuer: 'Example Corp Root CA', - systemStoreTrusts: undefined, - }); + await expect(result).resolves.toEqual({ systemStoreTrusts: false }); }); - it('resolves null when the handshake fails', async () => { - const socket = fakeSocket(false); + it('resolves null when the handshake fails for another reason', async () => { + const socket = fakeSocket(); const result = probeCertificateChain( 'us.posthog.com', fakeConnect(socket), store, ); - socket.emit('error', new Error('ECONNRESET')); + 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( + probeCertificateChain('us.posthog.com', connect, {}), + ).resolves.toBeNull(); + expect(connect).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/api.ts b/src/shared/api.ts index e48d62c73..22a403b6c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,7 +2,7 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; -import { probeCertificateChain } from './tls-probe'; +import { probeCertificateChain, UNTRUSTED_CERT_CODES } from './tls-probe'; import { WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; @@ -172,16 +172,6 @@ export class ApiError extends Error { } } -/** Node's codes for a certificate chain it can't verify, as TLS inspection causes. */ -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', -]); - function isUntrustedCertError(error: unknown): error is AxiosError { return ( axios.isAxiosError(error) && @@ -530,14 +520,10 @@ export async function diagnoseApiError( const host = hostOf(error); const result = host ? await probe(host) : null; if (host && result) { - const trusted = - result.systemStoreTrusts === undefined - ? 'unknown' - : String(result.systemStoreTrusts); logToFile( `[tls] ${host} chain signed by "${ result.issuer ?? 'unknown' - }"; trusted by the system store: ${trusted}`, + }"; trusted by the system store: ${String(result.systemStoreTrusts)}`, ); } return new ApiError( diff --git a/src/shared/tls-probe.ts b/src/shared/tls-probe.ts index 59fd78085..ac416d9c7 100644 --- a/src/shared/tls-probe.ts +++ b/src/shared/tls-probe.ts @@ -2,12 +2,22 @@ import tls, { type DetailedPeerCertificate } from 'node:tls'; /** What a handshake-only connection learned about a host's certificate chain. */ export type ChainProbe = { - /** Organization (or common name) of the topmost certificate's issuer. */ + /** 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; + 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[] }; @@ -40,9 +50,10 @@ function topIssuer(leaf: DetailedPeerCertificate): string | undefined { } /** - * Completes one TLS handshake with `host`, 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 handshake itself fails. + * 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 probeCertificateChain( host: string, @@ -50,14 +61,15 @@ export function probeCertificateChain( 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: 443, servername: host, ca, - // Read the chain rather than reject it; no request follows. - rejectUnauthorized: false, + rejectUnauthorized: true, timeout: PROBE_TIMEOUT_MS, }); const finish = (result: ChainProbe | null) => { @@ -67,10 +79,16 @@ export function probeCertificateChain( socket.once('secureConnect', () => finish({ issuer: topIssuer(socket.getPeerCertificate(true)), - systemStoreTrusts: ca ? socket.authorized : undefined, + systemStoreTrusts: true, }), ); socket.once('timeout', () => finish(null)); - socket.once('error', () => finish(null)); + socket.once('error', (error: NodeJS.ErrnoException) => + finish( + UNTRUSTED_CERT_CODES.has(error.code ?? '') + ? { systemStoreTrusts: false } + : null, + ), + ); }); } From 54a3cb16de8e99568b0c73972cf2d439595f2cbb Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 13:22:49 -0700 Subject: [PATCH 4/6] tls check --- src/shared/__tests__/api-errors.test.ts | 58 +++++++++++++++---- .../{tls-probe.test.ts => tls-check.test.ts} | 35 +++++++++-- src/shared/api.ts | 39 +++++++++++-- src/shared/{tls-probe.ts => tls-check.ts} | 32 +++++++--- 4 files changed, 133 insertions(+), 31 deletions(-) rename src/shared/__tests__/{tls-probe.test.ts => tls-check.test.ts} (73%) rename src/shared/{tls-probe.ts => tls-check.ts} (79%) diff --git a/src/shared/__tests__/api-errors.test.ts b/src/shared/__tests__/api-errors.test.ts index 3a6a4383d..74ea82ec9 100644 --- a/src/shared/__tests__/api-errors.test.ts +++ b/src/shared/__tests__/api-errors.test.ts @@ -128,9 +128,9 @@ describe('diagnoseApiError', () => { const certError = networkError('SELF_SIGNED_CERT_IN_CHAIN'); it.each([true, false])( - 'advises IT and records the probe when the system store trusts the chain: %s', + 'advises IT and records the check when the system store trusts the chain: %s', async (systemStoreTrusts) => { - const probe = vi.fn().mockResolvedValue({ + const check = vi.fn().mockResolvedValue({ issuer: 'Example Corp Root CA', systemStoreTrusts, }); @@ -138,10 +138,11 @@ describe('diagnoseApiError', () => { const error = await diagnoseApiError( certError, 'fetch project data', - probe, + check, + () => false, ); - expect(probe).toHaveBeenCalledWith('us.posthog.com'); + 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'); @@ -149,28 +150,61 @@ describe('diagnoseApiError', () => { }, ); - it('skips the probe for a local server', async () => { - const probe = vi.fn(); + 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', - probe, + check, + () => false, ); - expect(probe).not.toHaveBeenCalled(); + expect(check).not.toHaveBeenCalled(); }); - it('skips the probe for any other failure', async () => { - const probe = vi.fn(); + it('skips the check for any other failure', async () => { + const check = vi.fn(); const error = await diagnoseApiError( networkError('ECONNRESET'), 'fetch project data', - probe, + check, + () => false, ); - expect(probe).not.toHaveBeenCalled(); + expect(check).not.toHaveBeenCalled(); expect(error.message).toContain("Couldn't reach us.posthog.com"); }); }); diff --git a/src/shared/__tests__/tls-probe.test.ts b/src/shared/__tests__/tls-check.test.ts similarity index 73% rename from src/shared/__tests__/tls-probe.test.ts rename to src/shared/__tests__/tls-check.test.ts index d95629c7f..b7da5484f 100644 --- a/src/shared/__tests__/tls-probe.test.ts +++ b/src/shared/__tests__/tls-check.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type tls from 'node:tls'; -import { probeCertificateChain } from '../tls-probe'; +import { httpsProxyConfigured, checkCertificateChain } from '../tls-check'; type FakeSocket = EventEmitter & { destroy: ReturnType; @@ -37,12 +37,12 @@ const store = { type === 'system' ? ['bundled', 'keychain'] : ['bundled'], }; -describe('probeCertificateChain', () => { +describe('checkCertificateChain', () => { it('reports the top issuer when the system store verifies the chain', async () => { const socket = fakeSocket(); const connect = fakeConnect(socket); - const result = probeCertificateChain('us.posthog.com', connect, store); + const result = checkCertificateChain('us.posthog.com', 443, connect, store); socket.emit('secureConnect'); await expect(result).resolves.toEqual({ @@ -52,6 +52,7 @@ describe('probeCertificateChain', () => { expect(connect).toHaveBeenCalledWith( expect.objectContaining({ host: 'us.posthog.com', + port: 443, ca: ['bundled', 'keychain'], rejectUnauthorized: true, }), @@ -62,8 +63,9 @@ describe('probeCertificateChain', () => { it('reports untrusted when the chain fails verification', async () => { const socket = fakeSocket(); - const result = probeCertificateChain( + const result = checkCertificateChain( 'us.posthog.com', + 443, fakeConnect(socket), store, ); @@ -75,8 +77,9 @@ describe('probeCertificateChain', () => { it('resolves null when the handshake fails for another reason', async () => { const socket = fakeSocket(); - const result = probeCertificateChain( + const result = checkCertificateChain( 'us.posthog.com', + 443, fakeConnect(socket), store, ); @@ -89,8 +92,28 @@ describe('probeCertificateChain', () => { const connect = vi.fn() as unknown as typeof tls.connect; await expect( - probeCertificateChain('us.posthog.com', connect, {}), + checkCertificateChain('us.posthog.com', 443, connect, {}), ).resolves.toBeNull(); expect(connect).not.toHaveBeenCalled(); }); }); + +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); + }); +}); diff --git a/src/shared/api.ts b/src/shared/api.ts index 22a403b6c..c46e65909 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,7 +2,11 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; -import { probeCertificateChain, UNTRUSTED_CERT_CODES } from './tls-probe'; +import { + httpsProxyConfigured, + checkCertificateChain, + UNTRUSTED_CERT_CODES, +} from './tls-check'; import { WIZARD_USER_AGENT } from './constants'; import type { HostResolution } from './host-resolution'; @@ -164,7 +168,7 @@ export class ApiError extends Error { message: string, public readonly statusCode?: number, public readonly endpoint?: string, - /** Set after a TLS probe: whether the OS store would verify the host's chain. */ + /** Set after a TLS check: whether the OS store would verify the host's chain. */ public readonly systemStoreTrusts?: boolean, ) { super(message); @@ -485,6 +489,19 @@ function hostOf(error: AxiosError): string | 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. */ @@ -505,20 +522,30 @@ function untrustedCertMessage( } /** - * `handleApiError`, plus a handshake-only TLS probe when the chain didn't - * verify. The probe only diagnoses; the issuer goes to the local log alone, + * `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, - probe: typeof probeCertificateChain = probeCertificateChain, + check: typeof checkCertificateChain = checkCertificateChain, + proxyConfigured: () => boolean = httpsProxyConfigured, ): Promise { if (!isUntrustedCertError(error) || isLocalHost(error)) { return handleApiError(error, operation); } const host = hostOf(error); - const result = host ? await probe(host) : null; + 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 "${ diff --git a/src/shared/tls-probe.ts b/src/shared/tls-check.ts similarity index 79% rename from src/shared/tls-probe.ts rename to src/shared/tls-check.ts index ac416d9c7..cbc2620c1 100644 --- a/src/shared/tls-probe.ts +++ b/src/shared/tls-check.ts @@ -1,7 +1,7 @@ import tls, { type DetailedPeerCertificate } from 'node:tls'; /** What a handshake-only connection learned about a host's certificate chain. */ -export type ChainProbe = { +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. */ @@ -21,7 +21,24 @@ export const UNTRUSTED_CERT_CODES = new Set([ /** Node 22.15+ TLS API that the repo's `@types/node` predates. */ type CaStore = { getCACertificates?(type: 'default' | 'system'): string[] }; -const PROBE_TIMEOUT_MS = 5000; +const CHECK_TIMEOUT_MS = 5000; + +/** The variables axios reads (through proxy-from-env) for an https proxy. */ +const HTTPS_PROXY_VARS = [ + 'npm_config_https_proxy', + 'https_proxy', + 'HTTPS_PROXY', + 'npm_config_proxy', + 'all_proxy', + 'ALL_PROXY', +]; + +/** Whether the environment names an https proxy, which a direct check would bypass. */ +export function httpsProxyConfigured( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return HTTPS_PROXY_VARS.some((name) => Boolean(env[name])); +} function systemTrustedCAs(store: CaStore): string[] | undefined { try { @@ -55,24 +72,25 @@ function topIssuer(leaf: DetailedPeerCertificate): string | undefined { * 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 probeCertificateChain( +export function checkCertificateChain( host: string, + port: number, connect: typeof tls.connect = tls.connect, store: CaStore = tls as unknown as CaStore, -): Promise { +): 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: 443, + port, servername: host, ca, rejectUnauthorized: true, - timeout: PROBE_TIMEOUT_MS, + timeout: CHECK_TIMEOUT_MS, }); - const finish = (result: ChainProbe | null) => { + const finish = (result: ChainCheck | null) => { socket.destroy(); resolve(result); }; From 510ab7d3bf12f25d3ca6f1be3fe743f329577f0f Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 13:52:25 -0700 Subject: [PATCH 5/6] analytics --- main.ts | 4 + src/shared/__tests__/api-errors.test.ts | 48 +++++++++++- src/shared/__tests__/http-proxy.test.ts | 100 ++++++++++++++++++++++++ src/shared/__tests__/tls-check.test.ts | 22 +----- src/shared/api.ts | 24 ++++-- src/shared/http-proxy.ts | 100 ++++++++++++++++++++++++ src/shared/tls-check.ts | 17 ---- 7 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 src/shared/__tests__/http-proxy.test.ts create mode 100644 src/shared/http-proxy.ts 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 index 74ea82ec9..8de03727f 100644 --- a/src/shared/__tests__/api-errors.test.ts +++ b/src/shared/__tests__/api-errors.test.ts @@ -1,7 +1,17 @@ import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios'; -import { diagnoseApiError, handleApiError, retryTransient } from '../api'; +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() }; @@ -208,3 +218,39 @@ describe('diagnoseApiError', () => { 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..d8d267328 --- /dev/null +++ b/src/shared/__tests__/http-proxy.test.ts @@ -0,0 +1,100 @@ +import type axios from 'axios'; +import { analytics } from '@utils/analytics'; +import { httpsProxyConfigured, 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'; + +function fakeClient(): Pick { + return { defaults: {} } as unknown as Pick; +} + +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); + }); +}); diff --git a/src/shared/__tests__/tls-check.test.ts b/src/shared/__tests__/tls-check.test.ts index b7da5484f..395d61d00 100644 --- a/src/shared/__tests__/tls-check.test.ts +++ b/src/shared/__tests__/tls-check.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type tls from 'node:tls'; -import { httpsProxyConfigured, checkCertificateChain } from '../tls-check'; +import { checkCertificateChain } from '../tls-check'; type FakeSocket = EventEmitter & { destroy: ReturnType; @@ -97,23 +97,3 @@ describe('checkCertificateChain', () => { expect(connect).not.toHaveBeenCalled(); }); }); - -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); - }); -}); diff --git a/src/shared/api.ts b/src/shared/api.ts index c46e65909..ce9eb4e35 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -2,11 +2,8 @@ import axios, { AxiosError } from 'axios'; import { z } from 'zod'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; -import { - httpsProxyConfigured, - checkCertificateChain, - UNTRUSTED_CERT_CODES, -} from './tls-check'; +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'; @@ -221,6 +218,19 @@ function describeAxiosFailure(error: unknown): string { }`; } +/** 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, @@ -241,7 +251,7 @@ export async function fetchUserData( analytics.captureException(apiError, { endpoint: '/api/users/@me/', baseUrl, - system_store_trusts: apiError.systemStoreTrusts, + ...failureProperties(error, apiError), }); throw apiError; } @@ -312,7 +322,7 @@ export async function fetchProjectData( endpoint: `/api/projects/${projectId}/`, baseUrl, projectId, - system_store_trusts: apiError.systemStoreTrusts, + ...failureProperties(error, apiError), }); throw apiError; } diff --git a/src/shared/http-proxy.ts b/src/shared/http-proxy.ts new file mode 100644 index 000000000..0a8dfd7f2 --- /dev/null +++ b/src/shared/http-proxy.ts @@ -0,0 +1,100 @@ +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 }; + +/** 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: Pick = axios, +): EnvProxyMode { + const mode = applyEnvProxy(env, nodeVersion, client); + analytics.setTag('env_proxy', mode); + return mode; +} + +function applyEnvProxy( + env: NodeJS.ProcessEnv, + nodeVersion: string, + client: Pick, +): 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; + client.defaults.httpsAgent = new https.Agent( + options as unknown as https.AgentOptions, + ); + client.defaults.httpAgent = 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 index cbc2620c1..bbc99e5c5 100644 --- a/src/shared/tls-check.ts +++ b/src/shared/tls-check.ts @@ -23,23 +23,6 @@ type CaStore = { getCACertificates?(type: 'default' | 'system'): string[] }; const CHECK_TIMEOUT_MS = 5000; -/** The variables axios reads (through proxy-from-env) for an https proxy. */ -const HTTPS_PROXY_VARS = [ - 'npm_config_https_proxy', - 'https_proxy', - 'HTTPS_PROXY', - 'npm_config_proxy', - 'all_proxy', - 'ALL_PROXY', -]; - -/** Whether the environment names an https proxy, which a direct check would bypass. */ -export function httpsProxyConfigured( - env: NodeJS.ProcessEnv = process.env, -): boolean { - return HTTPS_PROXY_VARS.some((name) => Boolean(env[name])); -} - function systemTrustedCAs(store: CaStore): string[] | undefined { try { if (!store.getCACertificates) return undefined; From 51668e55f951e05a5f8933a913cba402e7703bcc Mon Sep 17 00:00:00 2001 From: Edwin Lim Date: Thu, 29 Oct 2026 14:06:40 -0700 Subject: [PATCH 6/6] scrub --- src/shared/__tests__/http-proxy.test.ts | 54 +++++++++++++++++++++++-- src/shared/http-proxy.ts | 47 ++++++++++++++++++--- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/src/shared/__tests__/http-proxy.test.ts b/src/shared/__tests__/http-proxy.test.ts index d8d267328..a19dc6a97 100644 --- a/src/shared/__tests__/http-proxy.test.ts +++ b/src/shared/__tests__/http-proxy.test.ts @@ -1,14 +1,24 @@ import type axios from 'axios'; import { analytics } from '@utils/analytics'; -import { httpsProxyConfigured, routeAxiosThroughEnvProxy } from '../http-proxy'; +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'; -function fakeClient(): Pick { - return { defaults: {} } as unknown as Pick; +type Client = Pick; + +function fakeClient(): Client { + return { + defaults: {}, + interceptors: { response: { use: vi.fn() } }, + } as unknown as Client; } describe('routeAxiosThroughEnvProxy', () => { @@ -98,3 +108,41 @@ describe('httpsProxyConfigured', () => { ).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/http-proxy.ts b/src/shared/http-proxy.ts index 0a8dfd7f2..b03d1fa71 100644 --- a/src/shared/http-proxy.ts +++ b/src/shared/http-proxy.ts @@ -45,6 +45,35 @@ export function httpsProxyConfigured( 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'; @@ -58,7 +87,7 @@ export type EnvProxyMode = 'none' | 'tunnel' | 'unsupported_node'; export function routeAxiosThroughEnvProxy( env: NodeJS.ProcessEnv = process.env, nodeVersion: string = process.version, - client: Pick = axios, + client: ProxyClient = axios, ): EnvProxyMode { const mode = applyEnvProxy(env, nodeVersion, client); analytics.setTag('env_proxy', mode); @@ -68,7 +97,7 @@ export function routeAxiosThroughEnvProxy( function applyEnvProxy( env: NodeJS.ProcessEnv, nodeVersion: string, - client: Pick, + client: ProxyClient, ): EnvProxyMode { const httpsProxy = firstSet(env, HTTPS_PROXY_VARS); const httpProxy = firstSet(env, HTTP_PROXY_VARS); @@ -89,11 +118,17 @@ function applyEnvProxy( // The repo's `@types/node` predates `proxyEnv`. const options: ProxyAgentOptions = { proxyEnv }; client.defaults.proxy = false; - client.defaults.httpsAgent = new https.Agent( - options as unknown as https.AgentOptions, + // 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 = new http.Agent( - options as unknown as http.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';