Blame Raw
Cohee · 51ad27fb · · 231 lines (9.9 KB)
1 contributor
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}