fix: guard self-signed routing request setup

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-30 14:54:27 -04:00
parent d49bbdaec3
commit 5807328cb5
2 changed files with 115 additions and 37 deletions
@@ -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<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 () => {
const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule();
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) => {
const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false;
@@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse(
callback();
};
let req: ReturnType<typeof https.request> | 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();
});
}