From 5807328cb58e6a60637a797b046afa06262043bf Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:27 -0400 Subject: [PATCH] fix: guard self-signed routing request setup --- .../__tests__/routing-strategy-http.test.ts | 70 +++++++++++++++- src/cliproxy/routing/routing-strategy-http.ts | 82 +++++++++++-------- 2 files changed, 115 insertions(+), 37 deletions(-) diff --git a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts index 3950665a..5f6682b9 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'bun:test'; +import * as https from 'https'; +import { describe, expect, it, mock, spyOn } from 'bun:test'; import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; async function loadRoutingHttpModule() { @@ -22,6 +23,73 @@ describe('routing-strategy-http', () => { ); }); + it('rejects malformed self-signed HTTPS URLs before arming the request timeout', async () => { + const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule(); + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => { + void handler; + void timeout; + return 1 as unknown as ReturnType; + }) as typeof setTimeout); + + const target: ProxyTarget = { + host: 'bad host', + port: 443, + protocol: 'https', + allowSelfSigned: true, + isRemote: true, + }; + + try { + await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow(); + expect(globalThis.setTimeout).not.toHaveBeenCalled(); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + }); + + it('clears the request timeout when https.request throws synchronously', async () => { + const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule(); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const activeTimers = new Set(); + + globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => { + void handler; + void timeout; + const timerId = activeTimers.size + 1; + activeTimers.add(timerId); + return timerId as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.clearTimeout = mock(((timerId?: ReturnType) => { + activeTimers.delete(timerId as unknown as number); + }) as typeof clearTimeout); + const requestSpy = spyOn(https, 'request').mockImplementation(() => { + throw new Error('sync request failure'); + }); + + const target: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + allowSelfSigned: true, + isRemote: true, + }; + + try { + await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow( + 'sync request failure' + ); + expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); + expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1); + expect(activeTimers.size).toBe(0); + } finally { + requestSpy.mockRestore(); + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + it('builds the remote management URL for routing strategy writes', async () => { const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule(); const target: ProxyTarget = { diff --git a/src/cliproxy/routing/routing-strategy-http.ts b/src/cliproxy/routing/routing-strategy-http.ts index 6fdaa37d..f361eb7e 100644 --- a/src/cliproxy/routing/routing-strategy-http.ts +++ b/src/cliproxy/routing/routing-strategy-http.ts @@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse( } } + const requestUrl = new URL(url); + return new Promise((resolve, reject) => { const agent = new https.Agent({ rejectUnauthorized: false }); let settled = false; @@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse( callback(); }; + let req: ReturnType | undefined; const timeoutId = setTimeout(() => { const error = new Error('Request timeout'); - req.destroy(error); + req?.destroy(error); settle(() => reject(error)); }, ROUTING_TIMEOUT_MS); - const req = https.request( - url, - { - method, - headers, - agent, - timeout: ROUTING_TIMEOUT_MS, - }, - (res) => { - let payload = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - payload += chunk; - }); - res.on('end', () => { - settle(() => - resolve( - new Response(payload, { - status: res.statusCode || 500, - statusText: res.statusMessage ?? '', - headers: - typeof res.headers['content-type'] === 'string' - ? { 'Content-Type': res.headers['content-type'] } - : undefined, - }) - ) - ); - }); - } - ); + try { + req = https.request( + requestUrl, + { + method, + headers, + agent, + timeout: ROUTING_TIMEOUT_MS, + }, + (res) => { + let payload = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + payload += chunk; + }); + res.on('end', () => { + settle(() => + resolve( + new Response(payload, { + status: res.statusCode || 500, + statusText: res.statusMessage ?? '', + headers: + typeof res.headers['content-type'] === 'string' + ? { 'Content-Type': res.headers['content-type'] } + : undefined, + }) + ) + ); + }); + } + ); + } catch (error) { + settle(() => reject(error)); + return; + } - req.on('error', (error) => { + const request = req; + + request.on('error', (error) => { settle(() => reject(error)); }); - req.on('timeout', () => { + request.on('timeout', () => { const error = new Error('Request timeout'); - req.destroy(error); + request.destroy(error); settle(() => reject(error)); }); if (body) { - req.write(JSON.stringify(body)); + request.write(JSON.stringify(body)); } - req.end(); + request.end(); }); }