fix: guard self-signed routing request setup

This commit is contained in:
Kai (Tam Nhu) Tran committed 2026-05-30 14:54:27 -04:00
1 parent d49bbdaec3
commit 5807328cb5
2 files changed
+115 -37

No files matched your search

@@ -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'; import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
async function loadRoutingHttpModule() { 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<typeof setTimeout>;
}) 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<number>();
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<typeof setTimeout>;
}) as typeof setTimeout);
globalThis.clearTimeout = mock(((timerId?: ReturnType<typeof setTimeout>) => {
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 () => { it('builds the remote management URL for routing strategy writes', async () => {
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule(); const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
const target: ProxyTarget = { const target: ProxyTarget = {
+46 -36
View File
@@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse(
} }
} }
const requestUrl = new URL(url);
return new Promise<Response>((resolve, reject) => { return new Promise<Response>((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false }); const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false; let settled = false;
@@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse(
callback(); callback();
}; };
let req: ReturnType<typeof https.request> | undefined;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
const error = new Error('Request timeout'); const error = new Error('Request timeout');
req.destroy(error); req?.destroy(error);
settle(() => reject(error)); settle(() => reject(error));
}, ROUTING_TIMEOUT_MS); }, ROUTING_TIMEOUT_MS);
const req = https.request( try {
url, req = https.request(
{ requestUrl,
method, {
headers, method,
agent, headers,
timeout: ROUTING_TIMEOUT_MS, agent,
}, timeout: ROUTING_TIMEOUT_MS,
(res) => { },
let payload = ''; (res) => {
res.setEncoding('utf8'); let payload = '';
res.on('data', (chunk) => { res.setEncoding('utf8');
payload += chunk; res.on('data', (chunk) => {
}); payload += chunk;
res.on('end', () => { });
settle(() => res.on('end', () => {
resolve( settle(() =>
new Response(payload, { resolve(
status: res.statusCode || 500, new Response(payload, {
statusText: res.statusMessage ?? '', status: res.statusCode || 500,
headers: statusText: res.statusMessage ?? '',
typeof res.headers['content-type'] === 'string' headers:
? { 'Content-Type': res.headers['content-type'] } typeof res.headers['content-type'] === 'string'
: undefined, ? { '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)); settle(() => reject(error));
}); });
req.on('timeout', () => { request.on('timeout', () => {
const error = new Error('Request timeout'); const error = new Error('Request timeout');
req.destroy(error); request.destroy(error);
settle(() => reject(error)); settle(() => reject(error));
}); });
if (body) { if (body) {
req.write(JSON.stringify(body)); request.write(JSON.stringify(body));
} }
req.end(); request.end();
}); });
} }