| 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 | } |