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:
151151 # - .trycloudflare.com
152152 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.
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+
154174# User session timeout *in seconds* (defaults to 24 hours).
155175## Set to a positive number to expire session after a certain time of inactivity
156176## Set to 0 to expire session when the browser is closed
package-lock.json+1 -0
@@ -38,6 +38,7 @@
3838 "@mozilla/readability": "^0.6.0",
3939 "@popperjs/core": "^2.11.8",
4040 "@zeldafan0225/ai_horde": "^5.2.0",
41+ "agent-base": "^7.1.3",
4142 "archiver": "^7.0.1",
4243 "bing-translate-api": "^4.1.0",
4344 "body-parser": "^1.20.2",
package.json+1 -0
@@ -29,6 +29,7 @@
2929 "@mozilla/readability": "^0.6.0",
3030 "@popperjs/core": "^2.11.8",
3131 "@zeldafan0225/ai_horde": "^5.2.0",
32+ "agent-base": "^7.1.3",
3233 "archiver": "^7.0.1",
3334 "bing-translate-api": "^4.1.0",
3435 "body-parser": "^1.20.2",
src/middleware/corsProxy.js+5 -1
@@ -37,6 +37,10 @@ export default async function corsProxyMiddleware(req, res) {
3737 // Copy over relevant response params to the proxy response
3838 await forwardFetchResponse(response, res);
3939 } 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();
4145 }
4246}
src/private-request-filter.js+231 -0
@@ -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+}
src/request-proxy.js+12 -3
@@ -14,14 +14,20 @@ const LOG_HEADER = '[Request Proxy]';
1414 * @property {string} url Proxy URL.
1515 * @property {string[]} bypass List of URLs to bypass proxy.
1616 * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive.
17+ * @property {boolean} privateRequestFilterEnabled Whether the private request filter is enabled.
1718 */
1819export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive, privateRequestFilterEnabled }) {
1920 try {
2021 // No proxy is enabled, so return
2122 if (!enabled) {
2223 return;
2324 }
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+
2531 if (!url) {
2632 console.error(color.red(LOG_HEADER), 'No proxy URL provided');
2733 return;
@@ -40,8 +46,11 @@ export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive
4046 process.env.no_proxy = bypass.join(',');
4147 }
4248
43- const proxyAgentOptions = enableKeepAlive ? { keepAlive: true } : { keepAlive: false };
49+ const httpAgent = http.globalAgent;
4450 const proxyAgenthttpsAgent = new ProxyAgent(proxyAgentOptions)https.globalAgent;
51+
52+ const proxyAgent = new ProxyAgent({ httpAgent, httpsAgent, keepAlive: enableKeepAlive });
53+
4554 http.globalAgent = proxyAgent;
4655 https.globalAgent = proxyAgent;
4756
src/server-main.js+14 -1
@@ -48,6 +48,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js';
4848import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js';
4949import multerMonkeyPatch from './middleware/multerMonkeyPatch.js';
5050import initRequestProxy from './request-proxy.js';
51+import initPrivateRequestFilter from './private-request-filter.js';
5152import cacheBuster from './middleware/cacheBuster.js';
5253import corsProxyMiddleware from './middleware/corsProxy.js';
5354import hostWhitelistMiddleware from './middleware/hostWhitelist.js';
@@ -333,8 +334,20 @@ async function preSetupTasks() {
333334 exitProcess();
334335 });
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+
336349 // Add request proxy.
337350 initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive, privateRequestFilterEnabled: requestFilterOptions.enabled });
338351
339352 // Wait for frontend libs to compile
340353 await webpackMiddleware.runWebpackCompiler({ pruneCache: true });
tests/private-request-filter.test.js+130 -0
@@ -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+});