Implement private IP range request host validator (#5497) * feat: implement private IP range request host validator for server-side HTTP requests * feat: add link-local address support * fix: use correct config keys * fix: if config missing use default loopback addresses * fix: re-use resolved address for connection * test: add unit coverage for private request filter and proxy interaction Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/1813593e-2263-45e2-aa53-74d39515f1df Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * test: remove request-proxy.test.js * perf: cache resolved matches * fix: remove unused import * fix: use proper ipv4 loopback cidr * fix: correct raiseError comment * test: uses tls.connect for secure endpoints * Implement private IP range request host validator Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/e76ba122-136e-43ad-b4bc-ea48a01fcdda Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert "Implement private IP range request host validator" This reverts commit 14e271470227b485b7d23caac31a237abf9f7835. * fix: close request without sending status in CORS forwarding when headers were sent * fix: not enabled -> disabled * feat: add enableKeepAlive option to PrivateRequestAgent Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com>

338119ab77309d5665d9d649377b81890829e158

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
8 files changed, +414 -5Ignore whitespace
default/config.yaml+20 -0
@@ -151,6 +151,26 @@ hostWhitelist:
151 # - .trycloudflare.com151 # - .trycloudflare.com
152 hosts: []152 hosts: []
153153
154# Perform whitelist checks against server-side HTTP requests that resolve to private IP addresses.
155# This is an additional layer of security to prevent Server-Side Request Forgery (SSRF) attacks.
156# Recommended when listen mode is enabled, or if your server is accessible by untrusted users.
157privateAddressWhitelist:
158 # Enable private address whitelist to block requests to private IP ranges.
159 enabled: false
160 # If true, requests to hosts that cannot be resolved will be allowed instead of blocked.
161 allowUnresolvedHosts: false
162 # Log blocked and allowed requests to the console.
163 log:
164 # Log blocked requests to the console with a warning message
165 blockedRequests: true
166 # Log allowed requests to the console with an info message
167 allowedRequests: false
168 # List of allowed private IP ranges (in CIDR notation or wildcard format).
169 # Allows loopback IP ranges by default, but you can customize this list to fit your needs.
170 allowedRanges:
171 - '127.0.0.0/8' # Loopback (IPv4)
172 - '::1/128' # Loopback (IPv6)
173
154# User session timeout *in seconds* (defaults to 24 hours).174# User session timeout *in seconds* (defaults to 24 hours).
155## Set to a positive number to expire session after a certain time of inactivity175## Set to a positive number to expire session after a certain time of inactivity
156## Set to 0 to expire session when the browser is closed176## Set to 0 to expire session when the browser is closed
package-lock.json+1 -0
@@ -38,6 +38,7 @@
38 "@mozilla/readability": "^0.6.0",38 "@mozilla/readability": "^0.6.0",
39 "@popperjs/core": "^2.11.8",39 "@popperjs/core": "^2.11.8",
40 "@zeldafan0225/ai_horde": "^5.2.0",40 "@zeldafan0225/ai_horde": "^5.2.0",
41 "agent-base": "^7.1.3",
41 "archiver": "^7.0.1",42 "archiver": "^7.0.1",
42 "bing-translate-api": "^4.1.0",43 "bing-translate-api": "^4.1.0",
43 "body-parser": "^1.20.2",44 "body-parser": "^1.20.2",
package.json+1 -0
@@ -29,6 +29,7 @@
29 "@mozilla/readability": "^0.6.0",29 "@mozilla/readability": "^0.6.0",
30 "@popperjs/core": "^2.11.8",30 "@popperjs/core": "^2.11.8",
31 "@zeldafan0225/ai_horde": "^5.2.0",31 "@zeldafan0225/ai_horde": "^5.2.0",
32 "agent-base": "^7.1.3",
32 "archiver": "^7.0.1",33 "archiver": "^7.0.1",
33 "bing-translate-api": "^4.1.0",34 "bing-translate-api": "^4.1.0",
34 "body-parser": "^1.20.2",35 "body-parser": "^1.20.2",
src/middleware/corsProxy.js+5 -1
@@ -37,6 +37,10 @@ export default async function corsProxyMiddleware(req, res) {
37 // Copy over relevant response params to the proxy response37 // Copy over relevant response params to the proxy response
38 await forwardFetchResponse(response, res);38 await forwardFetchResponse(response, res);
39 } catch (error) {39 } catch (error) {
40 res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error);40 console.error('Error in CORS proxy middleware:', error);
41 if (!res.headersSent) {
42 return res.sendStatus(500);
43 }
44 return res.end();
41 }45 }
42}46}
src/private-request-filter.js+231 -0
@@ -0,0 +1,231 @@
1import net from 'node:net';
2import tls from 'node:tls';
3import http from 'node:http';
4import https from 'node:https';
5import dns from 'node:dns';
6import ipMatch from 'ip-matching';
7import ipRegex from 'ip-regex';
8import { Agent } from 'agent-base';
9import { color } from './util.js';
10import { filterValidIpPatterns } from './express-common.js';
11
12const LOG_HEADER = '[Private Request Filter]';
13
14/** @type {import('ip-matching').IPMatch[]} */
15const privateIpRanges = [
16 // Loopback (IPv4)
17 ipMatch.getMatch('127.0.0.0/8'),
18 // Class A private network
19 ipMatch.getMatch('10.0.0.0/8'),
20 // Class B private network
21 ipMatch.getMatch('172.16.0.0/12'),
22 // Class C private network
23 ipMatch.getMatch('192.168.0.0/16'),
24 // Link-local address (IPv4)
25 ipMatch.getMatch('169.254.0.0/16'),
26 // Loopback (IPv6)
27 ipMatch.getMatch('::1/128'),
28 // Unique local address (IPv6)
29 ipMatch.getMatch('fc00::/7'),
30 // Link-local address (IPv6)
31 ipMatch.getMatch('fe80::/10'),
32];
33
34/**
35 * Custom HTTP/HTTPS agent that blocks requests to private IP addresses unless they are explicitly allowed in the private address whitelist.
36 * This is used to prevent Server-Side Request Forgery (SSRF) attacks by ensuring that the server cannot make requests to internal services or resources that are not intended to be exposed.
37 * The agent checks if the target host resolves to a private IP address and blocks the request if it does, unless the IP address is included in the private address whitelist.
38 * The private address whitelist can contain specific IP addresses or CIDR ranges that are allowed to be accessed even if they fall within private IP ranges.
39 */
40class PrivateRequestAgent extends Agent {
41 /**
42 * List of private IP addresses or CIDR ranges to allow
43 * @type {Readonly<import('ip-matching').IPMatch[]>}
44 */
45 privateAddressWhitelist = [];
46
47 /**
48 * Whether to log blocked requests to the console
49 * @type {boolean}
50 */
51 logBlocked = true;
52
53 /**
54 * Whether to log allowed requests to the console
55 * @type {boolean}
56 */
57 logAllowed = false;
58
59 /**
60 * Whether to allow requests to hosts that cannot be resolved
61 * @type {boolean}
62 */
63 allowUnresolvedHosts = false;
64
65 /**
66 * Create a new PrivateRequestAgent instance.
67 * @param {object} options
68 * @param {string[]} options.privateAddressWhitelist List of private IP addresses or CIDR ranges to allow.
69 * @param {boolean} options.logBlocked Whether to log blocked requests to the console.
70 * @param {boolean} options.logAllowed Whether to log allowed requests to the console.
71 * @param {boolean} options.allowUnresolvedHosts Whether to allow requests to hosts that cannot be resolved.
72 * @param {boolean} options.enableKeepAlive Whether to enable HTTP/HTTPS keep-alive.
73 */
74 constructor(options = { privateAddressWhitelist: [], logBlocked: true, logAllowed: false, allowUnresolvedHosts: false, enableKeepAlive: false }) {
75 super({ keepAlive: options.enableKeepAlive });
76
77 const logEntryWarning = (entry, message) => `${color.red('Warning')}: Ignoring invalid private whitelist entry ${color.yellow(entry)} - ${message}`;
78 const whitelistArray = Array.isArray(options.privateAddressWhitelist) ? options.privateAddressWhitelist : [];
79 this.privateAddressWhitelist = Object.freeze(filterValidIpPatterns(whitelistArray, logEntryWarning).map(pattern => ipMatch.getMatch(pattern)));
80 this.allowUnresolvedHosts = options.allowUnresolvedHosts;
81 this.logBlocked = options.logBlocked;
82 this.logAllowed = options.logAllowed;
83 }
84
85 /**
86 * Check if the given address is a private IP address.
87 * @param {string} address The IP address to check.
88 * @returns {boolean} Whether the given address is a private IP address.
89 */
90 #isPrivateIp(address) {
91 return privateIpRanges.some(range => range.matches(address));
92 }
93
94 /**
95 * Check if the given address is allowed based on the private address whitelist.
96 * @param {string} address The IP address to check.
97 * @returns {boolean} Whether the given address is allowed based on the private address whitelist.
98 */
99 #isAllowedPrivateAddress(address) {
100 // Permit the request if the private IP address is in the whitelist
101 return this.privateAddressWhitelist.some(match => match.matches(address));
102 }
103
104 /**
105 * Connect method that checks if the target host resolves to a private IP address and blocks the request if it does.
106 * @param {http.ClientRequest} _req HTTP request object.
107 * @param {import('agent-base').AgentConnectOpts} options Agent connection options.
108 */
109 async connect(_req, options) {
110 /**
111 * Raise an error and log it if necessary.
112 * @param {string} message The error message.
113 * @param {boolean} [log=true] Whether to log the error to the console.
114 */
115 const raiseError = (message, log = true) => {
116 if (log) {
117 console.error(color.red(LOG_HEADER), message);
118 }
119 throw new Error(message);
120 };
121
122 /**
123 * Establish a connection to the target host using either TLS or a regular socket based on the options provided.
124 * @param {string|null} [hostOverride] Pass a host to override the one in options when connecting.
125 * @returns {net.Socket|tls.TLSSocket} A socket connected to the target host.
126 */
127 const connect = (hostOverride = null) => {
128 if (hostOverride) {
129 options.host = hostOverride;
130 }
131 if (options.secureEndpoint) {
132 return tls.connect(options);
133 } else {
134 return net.connect(options);
135 }
136 };
137
138 /**
139 * Validate the given IP address against the private address whitelist and connect if it's allowed.
140 * @param {string} ip The IP address to validate.
141 * @returns {net.Socket|tls.TLSSocket} A socket connected to the target IP address if it's allowed, otherwise an error is raised.
142 */
143 const validateIpAddress = (ip) => {
144 // Not a private IP address, allow the request
145 if (!this.#isPrivateIp(ip)) {
146 return connect(ip);
147 }
148
149 // Private IP address, check if it's allowed in the whitelist
150 if (this.#isAllowedPrivateAddress(ip)) {
151 if (this.logAllowed) {
152 console.info(color.green(LOG_HEADER), 'Allowed request to private IP address:', color.blue(ip));
153 }
154
155 return connect(ip);
156 }
157
158 return raiseError(`Blocked request to private IP address: ${ip}`, this.logBlocked);
159 };
160
161 /**
162 * Resolve the given host to an IP address using DNS lookup.
163 * @param {string} host The host to resolve to an IP address.
164 * @returns {Promise<string>} The resolved IP address for the given host, or an empty string if the host cannot be resolved.
165 */
166 const lookupHost = async (host) => {
167 try {
168 return (await dns.promises.lookup(host)).address;
169 } catch {
170 return '';
171 }
172 };
173
174 const host = options.host;
175
176 if (!host) {
177 return raiseError('No host specified in request options', true);
178 }
179
180 const isIp = ipRegex.v4({ exact: true }).test(host) || ipRegex.v6({ exact: true }).test(host);
181
182 if (isIp) {
183 return validateIpAddress(host);
184 } else {
185 const address = await lookupHost(host);
186 if (!address) {
187 if (this.allowUnresolvedHosts) {
188 return connect();
189 } else {
190 return raiseError(`Unable to resolve host: ${host}. Set privateAddressWhitelist.allowUnresolvedHosts to true to bypass this check.`, true);
191 }
192 }
193
194 return validateIpAddress(address);
195 }
196 }
197}
198
199/**
200 * Initialize the private request filter by replacing the global HTTP and HTTPS agents with an instance of PrivateRequestAgent.
201 * @param {object} options Options for initializing the private request filter.
202 * @param {boolean} options.listen Whether the server is listening for incoming requests. This is used to determine whether to log a warning if the private request filter is not enabled.
203 * @param {boolean} options.enabled Whether the private request filter is enabled.
204 * @param {string[]} options.privateAddressWhitelist List of private IP addresses or CIDR ranges to allow.
205 * @param {boolean} options.logBlocked Whether to log blocked requests to the console.
206 * @param {boolean} options.logAllowed Whether to log allowed requests to the console.
207 * @param {boolean} options.allowUnresolvedHosts Whether to allow requests to hosts that cannot be resolved.
208 * @param {boolean} options.enableKeepAlive Whether to enable HTTP/HTTPS keep-alive.
209 */
210export default function initPrivateRequestFilter({ listen, enabled, privateAddressWhitelist, logBlocked, logAllowed, allowUnresolvedHosts, enableKeepAlive }) {
211 if (!enabled) {
212 if (listen) {
213 console.warn();
214 console.warn(color.yellow('Warning: listen is enabled but private request filter is disabled. This may expose your server to SSRF attacks.'));
215 console.warn(color.blue('To enable, provide trusted addresses in privateAddressWhitelist.allowedRanges and set privateAddressWhitelist.enabled to true in config.yaml and restart the server.'));
216 }
217 return;
218 }
219
220 const agent = new PrivateRequestAgent({ privateAddressWhitelist, logBlocked, logAllowed, allowUnresolvedHosts, enableKeepAlive });
221
222 http.globalAgent = agent;
223 https.globalAgent = agent;
224
225 console.info();
226 console.info(color.green(LOG_HEADER), 'Enabled');
227 if (agent.privateAddressWhitelist.length > 0) {
228 console.info(color.green(LOG_HEADER), 'Allowed private addresses:', color.blue(agent.privateAddressWhitelist.join(', ')));
229 }
230 console.info();
231}
src/request-proxy.js+12 -3
@@ -14,14 +14,20 @@ const LOG_HEADER = '[Request Proxy]';
14 * @property {string} url Proxy URL.14 * @property {string} url Proxy URL.
15 * @property {string[]} bypass List of URLs to bypass proxy.15 * @property {string[]} bypass List of URLs to bypass proxy.
16 * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive.16 * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive.
17 * @property {boolean} privateRequestFilterEnabled Whether the private request filter is enabled.
17 */18 */
18export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive }) {19export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive, privateRequestFilterEnabled }) {
19 try {20 try {
20 // No proxy is enabled, so return21 // No proxy is enabled, so return
21 if (!enabled) {22 if (!enabled) {
22 return;23 return;
23 }24 }
2425
26 if (privateRequestFilterEnabled) {
27 console.warn(color.yellow(LOG_HEADER), 'Warning: Request proxy is enabled while private request filter is also enabled. Only URLs that BYPASS the request proxy will be checked.');
28 console.warn(color.yellow(LOG_HEADER), 'To ensure all requests are properly filtered, disable the request proxy.');
29 }
30
25 if (!url) {31 if (!url) {
26 console.error(color.red(LOG_HEADER), 'No proxy URL provided');32 console.error(color.red(LOG_HEADER), 'No proxy URL provided');
27 return;33 return;
@@ -40,8 +46,11 @@ export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive
40 process.env.no_proxy = bypass.join(',');46 process.env.no_proxy = bypass.join(',');
41 }47 }
4248
43 const proxyAgentOptions = enableKeepAlive ? { keepAlive: true } : { keepAlive: false };49 const httpAgent = http.globalAgent;
44 const proxyAgent = new ProxyAgent(proxyAgentOptions);50 const httpsAgent = https.globalAgent;
51
52 const proxyAgent = new ProxyAgent({ httpAgent, httpsAgent, keepAlive: enableKeepAlive });
53
45 http.globalAgent = proxyAgent;54 http.globalAgent = proxyAgent;
46 https.globalAgent = proxyAgent;55 https.globalAgent = proxyAgent;
4756
src/server-main.js+14 -1
@@ -48,6 +48,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js';
48import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';48import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
49import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';49import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
50import initRequestProxy from './request-proxy.js';50import initRequestProxy from './request-proxy.js';
51import initPrivateRequestFilter from './private-request-filter.js';
51import cacheBuster from './middleware/cacheBuster.js';52import cacheBuster from './middleware/cacheBuster.js';
52import corsProxyMiddleware from './middleware/corsProxy.js';53import corsProxyMiddleware from './middleware/corsProxy.js';
53import hostWhitelistMiddleware from './middleware/hostWhitelist.js';54import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
@@ -333,8 +334,20 @@ async function preSetupTasks() {
333 exitProcess();334 exitProcess();
334 });335 });
335336
337 // Add private request filter.
338 const requestFilterOptions = {
339 listen: cliArgs.listen,
340 enabled: !!getConfigValue('privateAddressWhitelist.enabled', false, 'boolean'),
341 privateAddressWhitelist: getConfigValue('privateAddressWhitelist.allowedRanges', ['127.0.0.0/8', '::1/128']),
342 logBlocked: !!getConfigValue('privateAddressWhitelist.log.blockedRequests', true, 'boolean'),
343 logAllowed: !!getConfigValue('privateAddressWhitelist.log.allowedRequests', false, 'boolean'),
344 allowUnresolvedHosts: !!getConfigValue('privateAddressWhitelist.allowUnresolvedHosts', false, 'boolean'),
345 enableKeepAlive: cliArgs.enableKeepAlive,
346 };
347 initPrivateRequestFilter(requestFilterOptions);
348
336 // Add request proxy.349 // Add request proxy.
337 initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive });350 initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive, privateRequestFilterEnabled: requestFilterOptions.enabled });
338351
339 // Wait for frontend libs to compile352 // Wait for frontend libs to compile
340 await webpackMiddleware.runWebpackCompiler({ pruneCache: true });353 await webpackMiddleware.runWebpackCompiler({ pruneCache: true });
tests/private-request-filter.test.js+130 -0
@@ -0,0 +1,130 @@
1import { describe, test, expect, jest, beforeAll, beforeEach, afterAll } from '@jest/globals';
2
3const mockNetConnect = jest.fn(() => ({ type: 'net-socket' }));
4const mockTlsConnect = jest.fn(() => ({ type: 'tls-socket' }));
5const mockLookup = jest.fn();
6
7jest.unstable_mockModule('node:net', () => ({
8 default: { connect: mockNetConnect },
9}));
10
11jest.unstable_mockModule('node:tls', () => ({
12 default: { connect: mockTlsConnect },
13}));
14
15jest.unstable_mockModule('node:dns', () => ({
16 default: { promises: { lookup: mockLookup } },
17}));
18
19jest.unstable_mockModule('../src/util.js', () => ({
20 color: {
21 red: text => text,
22 green: text => text,
23 blue: text => text,
24 yellow: text => text,
25 },
26}));
27
28jest.unstable_mockModule('../src/express-common.js', () => ({
29 filterValidIpPatterns: patterns => patterns,
30}));
31
32/** @type {import('../src/private-request-filter.js').default} */
33let initPrivateRequestFilter;
34/** @type {import('node:http').default} */
35let http;
36/** @type {import('node:https').default} */
37let https;
38let originalHttpGlobalAgent;
39let originalHttpsGlobalAgent;
40
41beforeAll(async () => {
42 ({ default: initPrivateRequestFilter } = await import('../src/private-request-filter.js'));
43 ({ default: http } = await import('node:http'));
44 ({ default: https } = await import('node:https'));
45 originalHttpGlobalAgent = http.globalAgent;
46 originalHttpsGlobalAgent = https.globalAgent;
47});
48
49beforeEach(() => {
50 mockNetConnect.mockClear();
51 mockTlsConnect.mockClear();
52 mockLookup.mockReset();
53 http.globalAgent = originalHttpGlobalAgent;
54 https.globalAgent = originalHttpsGlobalAgent;
55});
56
57afterAll(() => {
58 http.globalAgent = originalHttpGlobalAgent;
59 https.globalAgent = originalHttpsGlobalAgent;
60});
61
62function initAgent({ privateAddressWhitelist = [], allowUnresolvedHosts = false } = {}) {
63 initPrivateRequestFilter({
64 listen: false,
65 enabled: true,
66 privateAddressWhitelist,
67 logBlocked: false,
68 logAllowed: false,
69 allowUnresolvedHosts,
70 });
71
72 return http.globalAgent;
73}
74
75describe('private request filter', () => {
76 test('allows direct private IP requests only when whitelisted', async () => {
77 const agent = initAgent({ privateAddressWhitelist: ['127.0.0.0/8'] });
78 await agent.connect({}, { host: '127.0.0.1', secureEndpoint: false });
79
80 expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '127.0.0.1' }));
81
82 const blockedAgent = initAgent({ privateAddressWhitelist: [] });
83 await expect(blockedAgent.connect({}, { host: '127.0.0.1', secureEndpoint: false }))
84 .rejects
85 .toThrow('Blocked request to private IP address: 127.0.0.1');
86 });
87
88 test('resolves hostnames and blocks when DNS returns private IP', async () => {
89 mockLookup.mockResolvedValue({ address: '192.168.1.8' });
90 const agent = initAgent();
91
92 await expect(agent.connect({}, { host: 'example.com', secureEndpoint: false }))
93 .rejects
94 .toThrow('Blocked request to private IP address: 192.168.1.8');
95 expect(mockNetConnect).not.toHaveBeenCalled();
96 });
97
98 test('connects to resolved public IP to avoid hostname re-resolution', async () => {
99 mockLookup.mockResolvedValue({ address: '93.184.216.34' });
100 const agent = initAgent();
101
102 await agent.connect({}, { host: 'example.com', secureEndpoint: false });
103
104 expect(mockLookup).toHaveBeenCalledWith('example.com');
105 expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' }));
106 });
107
108 test('handles unresolved hosts according to allowUnresolvedHosts setting', async () => {
109 mockLookup.mockRejectedValue(new Error('lookup failed'));
110 const blockedAgent = initAgent({ allowUnresolvedHosts: false });
111
112 await expect(blockedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false }))
113 .rejects
114 .toThrow('Unable to resolve host: missing-host.local. Set privateAddressWhitelist.allowUnresolvedHosts to true to bypass this check.');
115 expect(mockNetConnect).not.toHaveBeenCalled();
116
117 const allowedAgent = initAgent({ allowUnresolvedHosts: true });
118 await allowedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false });
119 expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: 'missing-host.local' }));
120 });
121
122 test('uses tls.connect for secure endpoints', async () => {
123 mockLookup.mockResolvedValue({ address: '93.184.216.34' });
124 const agent = initAgent();
125 await agent.connect({}, { host: 'example.com', secureEndpoint: true });
126 expect(mockLookup).toHaveBeenCalledWith('example.com');
127 expect(mockTlsConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' }));
128 expect(mockNetConnect).not.toHaveBeenCalled();
129 });
130});