diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..68f0198b5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security + +## SSRF Protection (v2.88.2) + +### Vulnerability + +The `request` package (upstream: `request/request`, now archived) follows HTTP redirects and resolves URLs without validating whether the resolved host points to an internal or private IP address. This allows Server-Side Request Forgery (SSRF) attacks where an attacker can trick a server into making requests to internal infrastructure, including: + +- Loopback addresses (127.0.0.0/8, ::1) +- Private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) +- Link-local and cloud metadata endpoints (169.254.0.0/16, including 169.254.169.254) +- IPv6 unique local (fc00::/7) and link-local (fe80::/10) addresses + +### Fix + +Version 2.88.2 adds SSRF protection that: + +1. Validates the target hostname/IP before every outgoing HTTP request (both initial requests and redirect targets). +2. Performs DNS resolution on hostnames and checks whether the resolved IP falls within private/internal ranges. +3. Blocks the request with a descriptive error if a private IP is detected. + +### Opting Out + +If your application intentionally makes requests to private/internal IPs, you can disable the SSRF protection per-request: + +```js +const request = require('@brickhouse-tech/request') + +request({ + uri: 'http://internal-service.local/api', + allowPrivateIPs: true +}, function (err, res, body) { + // ... +}) +``` + +### Reporting Security Issues + +Please report security issues to security@brickhouse.tech. + +## Maintained By + +[Brickhouse Tech](https://github.com/brickhouse-tech) provides long-term security maintenance for widely-used, abandoned npm packages. diff --git a/lib/ssrf.js b/lib/ssrf.js new file mode 100644 index 000000000..cddf0eca4 --- /dev/null +++ b/lib/ssrf.js @@ -0,0 +1,111 @@ +'use strict' + +var dns = require('dns') +var net = require('net') +var url = require('url') + +/** + * Check if an IP address falls within private/internal ranges. + * Blocks: loopback, private RFC1918, link-local, metadata endpoints. + * + * @param {string} ip - The IP address to check + * @returns {boolean} true if the IP is private/internal + */ +function isPrivateIP (ip) { + // IPv4 checks + if (net.isIPv4(ip)) { + var parts = ip.split('.').map(Number) + + // Loopback: 127.0.0.0/8 + if (parts[0] === 127) return true + + // Private: 10.0.0.0/8 + if (parts[0] === 10) return true + + // Private: 172.16.0.0/12 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true + + // Private: 192.168.0.0/16 + if (parts[0] === 192 && parts[1] === 168) return true + + // Link-local / cloud metadata: 169.254.0.0/16 + if (parts[0] === 169 && parts[1] === 254) return true + + // Current network: 0.0.0.0/8 + if (parts[0] === 0) return true + + return false + } + + // IPv6 checks + if (net.isIPv6(ip)) { + var normalized = ip.toLowerCase() + + // Loopback: ::1 + if (normalized === '::1') return true + + // Unique local addresses: fd00::/8 (and fc00::/7) + if (normalized.indexOf('fc') === 0 || normalized.indexOf('fd') === 0) return true + + // Link-local: fe80::/10 + if (normalized.indexOf('fe80') === 0) return true + + // IPv4-mapped IPv6: ::ffff:x.x.x.x + var v4mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized) + if (v4mapped) { + return isPrivateIP(v4mapped[1]) + } + + // Unspecified address + if (normalized === '::') return true + + return false + } + + return false +} + +/** + * Validate a URL's hostname against SSRF by resolving it via DNS + * and checking if the resolved IP is private/internal. + * + * @param {string|object} uri - URL string or parsed URL object + * @param {function} callback - callback(err) where err is set if SSRF detected + */ +function validateUri (uri, callback) { + var parsed = typeof uri === 'string' ? url.parse(uri) : uri + var hostname = parsed.hostname + + if (!hostname) { + return callback(null) + } + + // If the hostname is already an IP, check directly + if (net.isIP(hostname)) { + if (isPrivateIP(hostname)) { + return callback(new Error( + 'SSRF protection: request to private/internal IP ' + hostname + ' is blocked. ' + + 'Set { allowPrivateIPs: true } to override.' + )) + } + return callback(null) + } + + // Resolve hostname to IP and check + dns.lookup(hostname, function (err, address) { + if (err) { + // Let the normal request error handling deal with DNS failures + return callback(null) + } + if (isPrivateIP(address)) { + return callback(new Error( + 'SSRF protection: ' + hostname + ' resolves to private/internal IP ' + address + '. ' + + 'Set { allowPrivateIPs: true } to override.' + )) + } + return callback(null) + }) +} + +exports.isPrivateIP = isPrivateIP +exports.validateUri = validateUri diff --git a/package.json b/package.json index 86e4266bf..c63b097bb 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,20 @@ { - "name": "request", - "description": "Simplified HTTP request client.", + "name": "@brickhouse-tech/request", + "description": "Simplified HTTP request client. (maintained by Brickhouse Tech — SSRF security patch)", + "publishConfig": { + "access": "public" + }, "keywords": [ "http", "simple", "util", "utility" ], - "version": "2.88.1", + "version": "2.88.2", "author": "Mikeal Rogers ", "repository": { "type": "git", - "url": "https://github.com/request/request.git" + "url": "https://github.com/brickhouse-tech/request.git" }, "bugs": { "url": "http://github.com/request/request/issues" diff --git a/request.js b/request.js index 198b76093..b90fbf07d 100644 --- a/request.js +++ b/request.js @@ -27,6 +27,7 @@ var hawk = require('./lib/hawk') var Multipart = require('./lib/multipart').Multipart var Redirect = require('./lib/redirect').Redirect var Tunnel = require('./lib/tunnel').Tunnel +var ssrf = require('./lib/ssrf') var now = require('performance-now') var Buffer = require('safe-buffer').Buffer @@ -716,8 +717,8 @@ Request.prototype.start = function () { // by the high-resolution timer (via now()). While these two won't be set // at the _exact_ same time, they should be close enough to be able to calculate // high-resolution, monotonically non-decreasing timestamps relative to startTime. - var startTime = new Date().getTime() - var startTimeNow = now() + self.startTime = new Date().getTime() + self.startTimeNow = now() } if (self._aborted) { @@ -747,6 +748,24 @@ Request.prototype.start = function () { // consistency with node versions before v6.8.0 delete reqOptions.timeout + // SSRF protection: validate the target URI before making the request + if (!self.allowPrivateIPs) { + ssrf.validateUri(self.uri, function (err) { + if (err) { + self.emit('error', err) + return + } + self._makeRequest(reqOptions) + }) + return + } + + self._makeRequest(reqOptions) +} + +Request.prototype._makeRequest = function (reqOptions) { + var self = this + try { self.req = self.httpModule.request(reqOptions) } catch (err) { @@ -755,9 +774,6 @@ Request.prototype.start = function () { } if (self.timing) { - self.startTime = startTime - self.startTimeNow = startTimeNow - // Timing values will all be relative to startTime (by comparing to startTimeNow // so we have an accurate clock) self.timings = {}