From 338119ab77309d5665d9d649377b81890829e158 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:51:18 +0300 Subject: [PATCH] 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 --------- 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 --- default/config.yaml | 20 +++ package-lock.json | 1 + package.json | 1 + src/middleware/corsProxy.js | 6 +- src/private-request-filter.js | 231 +++++++++++++++++++++++++++ src/request-proxy.js | 15 +- src/server-main.js | 15 +- tests/private-request-filter.test.js | 130 +++++++++++++++ 8 files changed, 414 insertions(+), 5 deletions(-) create mode 100644 src/private-request-filter.js create mode 100644 tests/private-request-filter.test.js diff --git a/default/config.yaml b/default/config.yaml index 91d6cd291..e78aeba62 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -151,6 +151,26 @@ hostWhitelist: # - .trycloudflare.com hosts: [] +# Perform whitelist checks against server-side HTTP requests that resolve to private IP addresses. +# This is an additional layer of security to prevent Server-Side Request Forgery (SSRF) attacks. +# Recommended when listen mode is enabled, or if your server is accessible by untrusted users. +privateAddressWhitelist: + # Enable private address whitelist to block requests to private IP ranges. + enabled: false + # If true, requests to hosts that cannot be resolved will be allowed instead of blocked. + allowUnresolvedHosts: false + # Log blocked and allowed requests to the console. + log: + # Log blocked requests to the console with a warning message + blockedRequests: true + # Log allowed requests to the console with an info message + allowedRequests: false + # List of allowed private IP ranges (in CIDR notation or wildcard format). + # Allows loopback IP ranges by default, but you can customize this list to fit your needs. + allowedRanges: + - '127.0.0.0/8' # Loopback (IPv4) + - '::1/128' # Loopback (IPv6) + # User session timeout *in seconds* (defaults to 24 hours). ## Set to a positive number to expire session after a certain time of inactivity ## Set to 0 to expire session when the browser is closed diff --git a/package-lock.json b/package-lock.json index 9b20c6367..64a177d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@mozilla/readability": "^0.6.0", "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.2.0", + "agent-base": "^7.1.3", "archiver": "^7.0.1", "bing-translate-api": "^4.1.0", "body-parser": "^1.20.2", diff --git a/package.json b/package.json index 29e59cf97..e94ecf0d5 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@mozilla/readability": "^0.6.0", "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.2.0", + "agent-base": "^7.1.3", "archiver": "^7.0.1", "bing-translate-api": "^4.1.0", "body-parser": "^1.20.2", diff --git a/src/middleware/corsProxy.js b/src/middleware/corsProxy.js index 3235ae92f..cbe35568c 100644 --- a/src/middleware/corsProxy.js +++ b/src/middleware/corsProxy.js @@ -37,6 +37,10 @@ export default async function corsProxyMiddleware(req, res) { // Copy over relevant response params to the proxy response await forwardFetchResponse(response, res); } catch (error) { - res.status(500).send('Error occurred while trying to proxy to: ' + url + ' ' + error); + console.error('Error in CORS proxy middleware:', error); + if (!res.headersSent) { + return res.sendStatus(500); + } + return res.end(); } } diff --git a/src/private-request-filter.js b/src/private-request-filter.js new file mode 100644 index 000000000..a3eb75c0a --- /dev/null +++ b/src/private-request-filter.js @@ -0,0 +1,231 @@ +import net from 'node:net'; +import tls from 'node:tls'; +import http from 'node:http'; +import https from 'node:https'; +import dns from 'node:dns'; +import ipMatch from 'ip-matching'; +import ipRegex from 'ip-regex'; +import { Agent } from 'agent-base'; +import { color } from './util.js'; +import { filterValidIpPatterns } from './express-common.js'; + +const LOG_HEADER = '[Private Request Filter]'; + +/** @type {import('ip-matching').IPMatch[]} */ +const privateIpRanges = [ + // Loopback (IPv4) + ipMatch.getMatch('127.0.0.0/8'), + // Class A private network + ipMatch.getMatch('10.0.0.0/8'), + // Class B private network + ipMatch.getMatch('172.16.0.0/12'), + // Class C private network + ipMatch.getMatch('192.168.0.0/16'), + // Link-local address (IPv4) + ipMatch.getMatch('169.254.0.0/16'), + // Loopback (IPv6) + ipMatch.getMatch('::1/128'), + // Unique local address (IPv6) + ipMatch.getMatch('fc00::/7'), + // Link-local address (IPv6) + ipMatch.getMatch('fe80::/10'), +]; + +/** + * Custom HTTP/HTTPS agent that blocks requests to private IP addresses unless they are explicitly allowed in the private address whitelist. + * 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. + * 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. + * 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. + */ +class PrivateRequestAgent extends Agent { + /** + * List of private IP addresses or CIDR ranges to allow + * @type {Readonly} + */ + privateAddressWhitelist = []; + + /** + * Whether to log blocked requests to the console + * @type {boolean} + */ + logBlocked = true; + + /** + * Whether to log allowed requests to the console + * @type {boolean} + */ + logAllowed = false; + + /** + * Whether to allow requests to hosts that cannot be resolved + * @type {boolean} + */ + allowUnresolvedHosts = false; + + /** + * Create a new PrivateRequestAgent instance. + * @param {object} options + * @param {string[]} options.privateAddressWhitelist List of private IP addresses or CIDR ranges to allow. + * @param {boolean} options.logBlocked Whether to log blocked requests to the console. + * @param {boolean} options.logAllowed Whether to log allowed requests to the console. + * @param {boolean} options.allowUnresolvedHosts Whether to allow requests to hosts that cannot be resolved. + * @param {boolean} options.enableKeepAlive Whether to enable HTTP/HTTPS keep-alive. + */ + constructor(options = { privateAddressWhitelist: [], logBlocked: true, logAllowed: false, allowUnresolvedHosts: false, enableKeepAlive: false }) { + super({ keepAlive: options.enableKeepAlive }); + + const logEntryWarning = (entry, message) => `${color.red('Warning')}: Ignoring invalid private whitelist entry ${color.yellow(entry)} - ${message}`; + const whitelistArray = Array.isArray(options.privateAddressWhitelist) ? options.privateAddressWhitelist : []; + this.privateAddressWhitelist = Object.freeze(filterValidIpPatterns(whitelistArray, logEntryWarning).map(pattern => ipMatch.getMatch(pattern))); + this.allowUnresolvedHosts = options.allowUnresolvedHosts; + this.logBlocked = options.logBlocked; + this.logAllowed = options.logAllowed; + } + + /** + * Check if the given address is a private IP address. + * @param {string} address The IP address to check. + * @returns {boolean} Whether the given address is a private IP address. + */ + #isPrivateIp(address) { + return privateIpRanges.some(range => range.matches(address)); + } + + /** + * Check if the given address is allowed based on the private address whitelist. + * @param {string} address The IP address to check. + * @returns {boolean} Whether the given address is allowed based on the private address whitelist. + */ + #isAllowedPrivateAddress(address) { + // Permit the request if the private IP address is in the whitelist + return this.privateAddressWhitelist.some(match => match.matches(address)); + } + + /** + * Connect method that checks if the target host resolves to a private IP address and blocks the request if it does. + * @param {http.ClientRequest} _req HTTP request object. + * @param {import('agent-base').AgentConnectOpts} options Agent connection options. + */ + async connect(_req, options) { + /** + * Raise an error and log it if necessary. + * @param {string} message The error message. + * @param {boolean} [log=true] Whether to log the error to the console. + */ + const raiseError = (message, log = true) => { + if (log) { + console.error(color.red(LOG_HEADER), message); + } + throw new Error(message); + }; + + /** + * Establish a connection to the target host using either TLS or a regular socket based on the options provided. + * @param {string|null} [hostOverride] Pass a host to override the one in options when connecting. + * @returns {net.Socket|tls.TLSSocket} A socket connected to the target host. + */ + const connect = (hostOverride = null) => { + if (hostOverride) { + options.host = hostOverride; + } + if (options.secureEndpoint) { + return tls.connect(options); + } else { + return net.connect(options); + } + }; + + /** + * Validate the given IP address against the private address whitelist and connect if it's allowed. + * @param {string} ip The IP address to validate. + * @returns {net.Socket|tls.TLSSocket} A socket connected to the target IP address if it's allowed, otherwise an error is raised. + */ + const validateIpAddress = (ip) => { + // Not a private IP address, allow the request + if (!this.#isPrivateIp(ip)) { + return connect(ip); + } + + // Private IP address, check if it's allowed in the whitelist + if (this.#isAllowedPrivateAddress(ip)) { + if (this.logAllowed) { + console.info(color.green(LOG_HEADER), 'Allowed request to private IP address:', color.blue(ip)); + } + + return connect(ip); + } + + return raiseError(`Blocked request to private IP address: ${ip}`, this.logBlocked); + }; + + /** + * Resolve the given host to an IP address using DNS lookup. + * @param {string} host The host to resolve to an IP address. + * @returns {Promise} The resolved IP address for the given host, or an empty string if the host cannot be resolved. + */ + const lookupHost = async (host) => { + try { + return (await dns.promises.lookup(host)).address; + } catch { + return ''; + } + }; + + const host = options.host; + + if (!host) { + return raiseError('No host specified in request options', true); + } + + const isIp = ipRegex.v4({ exact: true }).test(host) || ipRegex.v6({ exact: true }).test(host); + + if (isIp) { + return validateIpAddress(host); + } else { + const address = await lookupHost(host); + if (!address) { + if (this.allowUnresolvedHosts) { + return connect(); + } else { + return raiseError(`Unable to resolve host: ${host}. Set privateAddressWhitelist.allowUnresolvedHosts to true to bypass this check.`, true); + } + } + + return validateIpAddress(address); + } + } +} + +/** + * Initialize the private request filter by replacing the global HTTP and HTTPS agents with an instance of PrivateRequestAgent. + * @param {object} options Options for initializing the private request filter. + * @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. + * @param {boolean} options.enabled Whether the private request filter is enabled. + * @param {string[]} options.privateAddressWhitelist List of private IP addresses or CIDR ranges to allow. + * @param {boolean} options.logBlocked Whether to log blocked requests to the console. + * @param {boolean} options.logAllowed Whether to log allowed requests to the console. + * @param {boolean} options.allowUnresolvedHosts Whether to allow requests to hosts that cannot be resolved. + * @param {boolean} options.enableKeepAlive Whether to enable HTTP/HTTPS keep-alive. + */ +export default function initPrivateRequestFilter({ listen, enabled, privateAddressWhitelist, logBlocked, logAllowed, allowUnresolvedHosts, enableKeepAlive }) { + if (!enabled) { + if (listen) { + console.warn(); + console.warn(color.yellow('Warning: listen is enabled but private request filter is disabled. This may expose your server to SSRF attacks.')); + 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.')); + } + return; + } + + const agent = new PrivateRequestAgent({ privateAddressWhitelist, logBlocked, logAllowed, allowUnresolvedHosts, enableKeepAlive }); + + http.globalAgent = agent; + https.globalAgent = agent; + + console.info(); + console.info(color.green(LOG_HEADER), 'Enabled'); + if (agent.privateAddressWhitelist.length > 0) { + console.info(color.green(LOG_HEADER), 'Allowed private addresses:', color.blue(agent.privateAddressWhitelist.join(', '))); + } + console.info(); +} diff --git a/src/request-proxy.js b/src/request-proxy.js index 496f917c1..02a235473 100644 --- a/src/request-proxy.js +++ b/src/request-proxy.js @@ -14,14 +14,20 @@ const LOG_HEADER = '[Request Proxy]'; * @property {string} url Proxy URL. * @property {string[]} bypass List of URLs to bypass proxy. * @property {boolean} enableKeepAlive Enable HTTP/HTTPS keep-alive. + * @property {boolean} privateRequestFilterEnabled Whether the private request filter is enabled. */ -export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive }) { +export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive, privateRequestFilterEnabled }) { try { // No proxy is enabled, so return if (!enabled) { return; } + if (privateRequestFilterEnabled) { + 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.'); + console.warn(color.yellow(LOG_HEADER), 'To ensure all requests are properly filtered, disable the request proxy.'); + } + if (!url) { console.error(color.red(LOG_HEADER), 'No proxy URL provided'); return; @@ -40,8 +46,11 @@ export default function initRequestProxy({ enabled, url, bypass, enableKeepAlive process.env.no_proxy = bypass.join(','); } - const proxyAgentOptions = enableKeepAlive ? { keepAlive: true } : { keepAlive: false }; - const proxyAgent = new ProxyAgent(proxyAgentOptions); + const httpAgent = http.globalAgent; + const httpsAgent = https.globalAgent; + + const proxyAgent = new ProxyAgent({ httpAgent, httpsAgent, keepAlive: enableKeepAlive }); + http.globalAgent = proxyAgent; https.globalAgent = proxyAgent; diff --git a/src/server-main.js b/src/server-main.js index b412a63cd..00d446826 100644 --- a/src/server-main.js +++ b/src/server-main.js @@ -48,6 +48,7 @@ import getWhitelistMiddleware from './middleware/whitelist.js'; import accessLoggerMiddleware, { getAccessLogPath, migrateAccessLog } from './middleware/accessLogWriter.js'; import multerMonkeyPatch from './middleware/multerMonkeyPatch.js'; import initRequestProxy from './request-proxy.js'; +import initPrivateRequestFilter from './private-request-filter.js'; import cacheBuster from './middleware/cacheBuster.js'; import corsProxyMiddleware from './middleware/corsProxy.js'; import hostWhitelistMiddleware from './middleware/hostWhitelist.js'; @@ -333,8 +334,20 @@ async function preSetupTasks() { exitProcess(); }); + // Add private request filter. + const requestFilterOptions = { + listen: cliArgs.listen, + enabled: !!getConfigValue('privateAddressWhitelist.enabled', false, 'boolean'), + privateAddressWhitelist: getConfigValue('privateAddressWhitelist.allowedRanges', ['127.0.0.0/8', '::1/128']), + logBlocked: !!getConfigValue('privateAddressWhitelist.log.blockedRequests', true, 'boolean'), + logAllowed: !!getConfigValue('privateAddressWhitelist.log.allowedRequests', false, 'boolean'), + allowUnresolvedHosts: !!getConfigValue('privateAddressWhitelist.allowUnresolvedHosts', false, 'boolean'), + enableKeepAlive: cliArgs.enableKeepAlive, + }; + initPrivateRequestFilter(requestFilterOptions); + // Add request proxy. - initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive }); + initRequestProxy({ enabled: cliArgs.requestProxyEnabled, url: cliArgs.requestProxyUrl, bypass: cliArgs.requestProxyBypass, enableKeepAlive: cliArgs.enableKeepAlive, privateRequestFilterEnabled: requestFilterOptions.enabled }); // Wait for frontend libs to compile await webpackMiddleware.runWebpackCompiler({ pruneCache: true }); diff --git a/tests/private-request-filter.test.js b/tests/private-request-filter.test.js new file mode 100644 index 000000000..88b0703d8 --- /dev/null +++ b/tests/private-request-filter.test.js @@ -0,0 +1,130 @@ +import { describe, test, expect, jest, beforeAll, beforeEach, afterAll } from '@jest/globals'; + +const mockNetConnect = jest.fn(() => ({ type: 'net-socket' })); +const mockTlsConnect = jest.fn(() => ({ type: 'tls-socket' })); +const mockLookup = jest.fn(); + +jest.unstable_mockModule('node:net', () => ({ + default: { connect: mockNetConnect }, +})); + +jest.unstable_mockModule('node:tls', () => ({ + default: { connect: mockTlsConnect }, +})); + +jest.unstable_mockModule('node:dns', () => ({ + default: { promises: { lookup: mockLookup } }, +})); + +jest.unstable_mockModule('../src/util.js', () => ({ + color: { + red: text => text, + green: text => text, + blue: text => text, + yellow: text => text, + }, +})); + +jest.unstable_mockModule('../src/express-common.js', () => ({ + filterValidIpPatterns: patterns => patterns, +})); + +/** @type {import('../src/private-request-filter.js').default} */ +let initPrivateRequestFilter; +/** @type {import('node:http').default} */ +let http; +/** @type {import('node:https').default} */ +let https; +let originalHttpGlobalAgent; +let originalHttpsGlobalAgent; + +beforeAll(async () => { + ({ default: initPrivateRequestFilter } = await import('../src/private-request-filter.js')); + ({ default: http } = await import('node:http')); + ({ default: https } = await import('node:https')); + originalHttpGlobalAgent = http.globalAgent; + originalHttpsGlobalAgent = https.globalAgent; +}); + +beforeEach(() => { + mockNetConnect.mockClear(); + mockTlsConnect.mockClear(); + mockLookup.mockReset(); + http.globalAgent = originalHttpGlobalAgent; + https.globalAgent = originalHttpsGlobalAgent; +}); + +afterAll(() => { + http.globalAgent = originalHttpGlobalAgent; + https.globalAgent = originalHttpsGlobalAgent; +}); + +function initAgent({ privateAddressWhitelist = [], allowUnresolvedHosts = false } = {}) { + initPrivateRequestFilter({ + listen: false, + enabled: true, + privateAddressWhitelist, + logBlocked: false, + logAllowed: false, + allowUnresolvedHosts, + }); + + return http.globalAgent; +} + +describe('private request filter', () => { + test('allows direct private IP requests only when whitelisted', async () => { + const agent = initAgent({ privateAddressWhitelist: ['127.0.0.0/8'] }); + await agent.connect({}, { host: '127.0.0.1', secureEndpoint: false }); + + expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '127.0.0.1' })); + + const blockedAgent = initAgent({ privateAddressWhitelist: [] }); + await expect(blockedAgent.connect({}, { host: '127.0.0.1', secureEndpoint: false })) + .rejects + .toThrow('Blocked request to private IP address: 127.0.0.1'); + }); + + test('resolves hostnames and blocks when DNS returns private IP', async () => { + mockLookup.mockResolvedValue({ address: '192.168.1.8' }); + const agent = initAgent(); + + await expect(agent.connect({}, { host: 'example.com', secureEndpoint: false })) + .rejects + .toThrow('Blocked request to private IP address: 192.168.1.8'); + expect(mockNetConnect).not.toHaveBeenCalled(); + }); + + test('connects to resolved public IP to avoid hostname re-resolution', async () => { + mockLookup.mockResolvedValue({ address: '93.184.216.34' }); + const agent = initAgent(); + + await agent.connect({}, { host: 'example.com', secureEndpoint: false }); + + expect(mockLookup).toHaveBeenCalledWith('example.com'); + expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' })); + }); + + test('handles unresolved hosts according to allowUnresolvedHosts setting', async () => { + mockLookup.mockRejectedValue(new Error('lookup failed')); + const blockedAgent = initAgent({ allowUnresolvedHosts: false }); + + await expect(blockedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false })) + .rejects + .toThrow('Unable to resolve host: missing-host.local. Set privateAddressWhitelist.allowUnresolvedHosts to true to bypass this check.'); + expect(mockNetConnect).not.toHaveBeenCalled(); + + const allowedAgent = initAgent({ allowUnresolvedHosts: true }); + await allowedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false }); + expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: 'missing-host.local' })); + }); + + test('uses tls.connect for secure endpoints', async () => { + mockLookup.mockResolvedValue({ address: '93.184.216.34' }); + const agent = initAgent(); + await agent.connect({}, { host: 'example.com', secureEndpoint: true }); + expect(mockLookup).toHaveBeenCalledWith('example.com'); + expect(mockTlsConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' })); + expect(mockNetConnect).not.toHaveBeenCalled(); + }); +});