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>
Signed| @@ -151,6 +151,26 @@ hostWhitelist: | ||
| 151 | 151 | # - .trycloudflare.com |
| 152 | 152 | hosts: [] |
| 153 | 153 | |
| 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. | |
| 157 | +privateAddressWhitelist: | |
| 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 | 174 | # User session timeout *in seconds* (defaults to 24 hours). |
| 155 | 175 | ## Set to a positive number to expire session after a certain time of inactivity |
| 156 | 176 | ## Set to 0 to expire session when the browser is closed |
| @@ -38,6 +38,7 @@ | ||
| 38 | 38 | "@mozilla/readability": "^0.6.0", |
| 39 | 39 | "@popperjs/core": "^2.11.8", |
| 40 | 40 | "@zeldafan0225/ai_horde": "^5.2.0", |
| 41 | + "agent-base": "^7.1.3", | |
| 41 | 42 | "archiver": "^7.0.1", |
| 42 | 43 | "bing-translate-api": "^4.1.0", |
| 43 | 44 | "body-parser": "^1.20.2", |
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | "@mozilla/readability": "^0.6.0", |
| 30 | 30 | "@popperjs/core": "^2.11.8", |
| 31 | 31 | "@zeldafan0225/ai_horde": "^5.2.0", |
| 32 | + "agent-base": "^7.1.3", | |
| 32 | 33 | "archiver": "^7.0.1", |
| 33 | 34 | "bing-translate-api": "^4.1.0", |
| 34 | 35 | "body-parser": "^1.20.2", |
| @@ -37,6 +37,10 @@ export default async function corsProxyMiddleware(req, res) { | ||
| 37 | 37 | // Copy over relevant response params to the proxy response |
| 38 | 38 | await forwardFetchResponse(response, res); |
| 39 | 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 | } |
| @@ -0,0 +1,231 @@ | ||
| 1 | +import net from 'node:net'; | |
| 2 | +import tls from 'node:tls'; | |
| 3 | +import http from 'node:http'; | |
| 4 | +import https from 'node:https'; | |
| 5 | +import dns from 'node:dns'; | |
| 6 | +import ipMatch from 'ip-matching'; | |
| 7 | +import ipRegex from 'ip-regex'; | |
| 8 | +import { Agent } from 'agent-base'; | |
| 9 | +import { color } from './util.js'; | |
| 10 | +import { filterValidIpPatterns } from './express-common.js'; | |
| 11 | + | |
| 12 | +const LOG_HEADER = '[Private Request Filter]'; | |
| 13 | + | |
| 14 | +/** @type {import('ip-matching').IPMatch[]} */ | |
| 15 | +const 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 | + */ | |
| 40 | +class 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 | + */ | |
| 210 | +export 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 | +} | |
| @@ -14,14 +14,20 @@ const LOG_HEADER = '[Request Proxy]'; | ||
| 14 | 14 | * @property {string} url Proxy URL. |
| 15 | 15 | * @property {string[]} bypass List of URLs to bypass proxy. |
| 16 | 16 | * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive. |
| 17 | + * @property {boolean} privateRequestFilterEnabled Whether the private request filter is enabled. | |
| 17 | 18 | */ |
| 18 | 19 | export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive, privateRequestFilterEnabled }) { |
| 19 | 20 | try { |
| 20 | 21 | // No proxy is enabled, so return |
| 21 | 22 | if (!enabled) { |
| 22 | 23 | return; |
| 23 | 24 | } |
| 24 | 25 | |
| 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 | 31 | if (!url) { |
| 26 | 32 | console.error(color.red(LOG_HEADER), 'No proxy URL provided'); |
| 27 | 33 | return; |
| @@ -40,8 +46,11 @@ export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive | ||
| 40 | 46 | process.env.no_proxy = bypass.join(','); |
| 41 | 47 | } |
| 42 | 48 | |
| 43 | - const proxyAgentOptions = enableKeepAlive ? { keepAlive: true } : { keepAlive: false }; | |
| 49 | + const httpAgent = http.globalAgent; | |
| 44 | 50 | const proxyAgenthttpsAgent = new ProxyAgent(proxyAgentOptions)https.globalAgent; |
| 51 | + | |
| 52 | + const proxyAgent = new ProxyAgent({ httpAgent, httpsAgent, keepAlive: enableKeepAlive }); | |
| 53 | + | |
| 45 | 54 | http.globalAgent = proxyAgent; |
| 46 | 55 | https.globalAgent = proxyAgent; |
| 47 | 56 | |
| @@ -48,6 +48,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js'; | ||
| 48 | 48 | import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; |
| 49 | 49 | import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; |
| 50 | 50 | import initRequestProxy from './request-proxy.js'; |
| 51 | +import initPrivateRequestFilter from './private-request-filter.js'; | |
| 51 | 52 | import cacheBuster from './middleware/cacheBuster.js'; |
| 52 | 53 | import corsProxyMiddleware from './middleware/corsProxy.js'; |
| 53 | 54 | import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; |
| @@ -333,8 +334,20 @@ async function preSetupTasks() { | ||
| 333 | 334 | exitProcess(); |
| 334 | 335 | }); |
| 335 | 336 | |
| 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 | 349 | // Add request proxy. |
| 337 | 350 | initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive, privateRequestFilterEnabled: requestFilterOptions.enabled }); |
| 338 | 351 | |
| 339 | 352 | // Wait for frontend libs to compile |
| 340 | 353 | await webpackMiddleware.runWebpackCompiler({ pruneCache: true }); |
| @@ -0,0 +1,130 @@ | ||
| 1 | +import { describe, test, expect, jest, beforeAll, beforeEach, afterAll } from '@jest/globals'; | |
| 2 | + | |
| 3 | +const mockNetConnect = jest.fn(() => ({ type: 'net-socket' })); | |
| 4 | +const mockTlsConnect = jest.fn(() => ({ type: 'tls-socket' })); | |
| 5 | +const mockLookup = jest.fn(); | |
| 6 | + | |
| 7 | +jest.unstable_mockModule('node:net', () => ({ | |
| 8 | + default: { connect: mockNetConnect }, | |
| 9 | +})); | |
| 10 | + | |
| 11 | +jest.unstable_mockModule('node:tls', () => ({ | |
| 12 | + default: { connect: mockTlsConnect }, | |
| 13 | +})); | |
| 14 | + | |
| 15 | +jest.unstable_mockModule('node:dns', () => ({ | |
| 16 | + default: { promises: { lookup: mockLookup } }, | |
| 17 | +})); | |
| 18 | + | |
| 19 | +jest.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 | + | |
| 28 | +jest.unstable_mockModule('../src/express-common.js', () => ({ | |
| 29 | + filterValidIpPatterns: patterns => patterns, | |
| 30 | +})); | |
| 31 | + | |
| 32 | +/** @type {import('../src/private-request-filter.js').default} */ | |
| 33 | +let initPrivateRequestFilter; | |
| 34 | +/** @type {import('node:http').default} */ | |
| 35 | +let http; | |
| 36 | +/** @type {import('node:https').default} */ | |
| 37 | +let https; | |
| 38 | +let originalHttpGlobalAgent; | |
| 39 | +let originalHttpsGlobalAgent; | |
| 40 | + | |
| 41 | +beforeAll(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 | + | |
| 49 | +beforeEach(() => { | |
| 50 | + mockNetConnect.mockClear(); | |
| 51 | + mockTlsConnect.mockClear(); | |
| 52 | + mockLookup.mockReset(); | |
| 53 | + http.globalAgent = originalHttpGlobalAgent; | |
| 54 | + https.globalAgent = originalHttpsGlobalAgent; | |
| 55 | +}); | |
| 56 | + | |
| 57 | +afterAll(() => { | |
| 58 | + http.globalAgent = originalHttpGlobalAgent; | |
| 59 | + https.globalAgent = originalHttpsGlobalAgent; | |
| 60 | +}); | |
| 61 | + | |
| 62 | +function 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 | + | |
| 75 | +describe('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 | +}); | |