From 508b6d3253bc658bee990d3be319727e5667bca2 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan <60182103+abhu85@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:39:43 +0000 Subject: [PATCH 1/2] fix: address SSRF bypass patterns in isPrivate/isPublic (CVE-2024-29415) This commit fixes multiple SSRF bypass patterns that allow attackers to bypass isPublic()/isPrivate() checks: 1. Null route "0" - now correctly identified as private (0.0.0.0) 2. 32-bit octal format "017700000001" - now correctly identified as loopback 3. IPv6 loopback variations (::1, 0:0:0:0:0:0:0:1) - improved detection 4. IPv6-mapped IPv4 loopback in hex (::ffff:7f00:1) - now detected 5. Short-form IPs (127.1, 127.0.1) - already handled, added tests Changes: - isLoopback() now normalizes IPv4 addresses before checking - isPrivate() now checks for 0.0.0.0/8 range (null route) - Added helper to extract IPv4 from IPv6-mapped addresses - Comprehensive test coverage for all bypass patterns Fixes: CVE-2024-29415 Refs: #150, #158, #160, #162 --- lib/ip.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 146 insertions(+), 24 deletions(-) diff --git a/lib/ip.js b/lib/ip.js index 9022443..d816e4f 100644 --- a/lib/ip.js +++ b/lib/ip.js @@ -305,32 +305,128 @@ ip.isEqual = function (a, b) { return true; }; +/** + * Extract IPv4 address from IPv6-mapped address if applicable. + * Handles formats like: + * - ::ffff:127.0.0.1 (dot notation) + * - ::ffff:7f00:1 (hex notation) + * - 0:0:0:0:0:ffff:127.0.0.1 (expanded) + * - 0:0:0:0:0:ffff:7f00:1 (expanded hex) + * Returns null if not an IPv6-mapped IPv4 address. + * @private + */ +function _extractIPv4FromMapped(addr) { + // Match ::ffff: prefix with dot-notation IPv4 + const dotMatch = addr.match(/^(?:0:){0,5}(?:::)?f{4}:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i); + if (dotMatch) { + return dotMatch[1]; + } + + // Match ::ffff: prefix with hex-notation IPv4 (e.g., ::ffff:7f00:1) + const hexMatch = addr.match(/^(?:0:){0,5}(?:::)?f{4}:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (hexMatch) { + const high = parseInt(hexMatch[1], 16); + const low = parseInt(hexMatch[2], 16); + return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`; + } + + return null; +} + +/** + * Check if an IPv6 address is a loopback address. + * Handles various representations: + * - ::1 (compressed) + * - 0:0:0:0:0:0:0:1 (expanded) + * - 0000:0000:0000:0000:0000:0000:0000:0001 (full) + * - :: (unspecified, treated as loopback for safety) + * @private + */ +function _isIPv6Loopback(addr) { + // Normalize: remove leading zeros in each segment, lowercase + const normalized = addr.toLowerCase().split(':').map(seg => { + if (seg === '') return ''; + return parseInt(seg, 16).toString(16); + }).join(':'); + + // Check for ::1 or expanded forms + if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') { + return true; + } + + // Check for :: (unspecified address - treat as private for safety) + if (normalized === '::' || normalized === '0:0:0:0:0:0:0:0' || addr === '::') { + return true; + } + + // Handle compressed forms like 0::1, ::0:1, etc. + // Expand :: and check if result is ::1 + if (addr.includes('::')) { + const parts = addr.split('::'); + const left = parts[0] ? parts[0].split(':') : []; + const right = parts[1] ? parts[1].split(':') : []; + const missing = 8 - left.length - right.length; + const full = [...left, ...Array(missing).fill('0'), ...right]; + const expanded = full.map(s => parseInt(s || '0', 16)).join(':'); + if (expanded === '0:0:0:0:0:0:0:1' || expanded === '0:0:0:0:0:0:0:0') { + return true; + } + } + + return false; +} + ip.isPrivate = function (addr) { // check loopback addresses first if (ip.isLoopback(addr)) { return true; } - // ensure the ipv4 address is valid - if (!ip.isV6Format(addr)) { - const ipl = ip.normalizeToLong(addr); - if (ipl < 0) { - throw new Error('invalid ipv4 address'); + // Handle IPv6 addresses + if (ip.isV6Format(addr)) { + // Check for IPv6-mapped IPv4 addresses + const mappedIPv4 = _extractIPv4FromMapped(addr); + if (mappedIPv4) { + // Recursively check the extracted IPv4 address + return ip.isPrivate(mappedIPv4); } - // normalize the address for the private range checks that follow - addr = ip.fromLong(ipl); - } - - // check private ranges - return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) - || /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) - || /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i - .test(addr) - || /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) - || /^f[cd][0-9a-f]{2}:/i.test(addr) - || /^fe80:/i.test(addr) - || /^::1$/.test(addr) - || /^::$/.test(addr); + + // Check IPv6 private ranges + return /^f[cd][0-9a-f]{2}:/i.test(addr) // fc00::/7 - Unique local addresses + || /^fe80:/i.test(addr) // fe80::/10 - Link-local addresses + || /^::1$/.test(addr) // ::1 - Loopback + || /^::$/.test(addr) // :: - Unspecified (treat as private) + || _isIPv6Loopback(addr); // Other loopback representations + } + + // Handle IPv4 addresses (including non-standard formats) + const ipl = ip.normalizeToLong(addr); + if (ipl < 0) { + throw new Error('invalid ipv4 address'); + } + + // Normalize the address for the private range checks + const normalizedAddr = ip.fromLong(ipl); + + // Check private IPv4 ranges: + // 0.0.0.0/8 - "This" network (null route, local) + // 10.0.0.0/8 - Private + // 127.0.0.0/8 - Loopback + // 169.254.0.0/16 - Link-local + // 172.16.0.0/12 - Private + // 192.168.0.0/16 - Private + + // Check 0.0.0.0/8 (null route / "this" network) + if (/^0\./.test(normalizedAddr)) { + return true; + } + + // Check standard private ranges + return /^10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.test(normalizedAddr) + || /^192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/.test(normalizedAddr) + || /^172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/.test(normalizedAddr) + || /^169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/.test(normalizedAddr) + || /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.test(normalizedAddr); }; ip.isPublic = function (addr) { @@ -338,13 +434,39 @@ ip.isPublic = function (addr) { }; ip.isLoopback = function (addr) { - // If addr is an IPv4 address in long integer form (no dots and no colons), convert it - if (!/\./.test(addr) && !/:/.test(addr)) { - addr = ip.fromLong(Number(addr)); + // Handle IPv6 addresses + if (ip.isV6Format(addr)) { + // Check for IPv6-mapped IPv4 loopback + const mappedIPv4 = _extractIPv4FromMapped(addr); + if (mappedIPv4) { + return ip.isLoopback(mappedIPv4); + } + + // Check IPv6 loopback representations + return _isIPv6Loopback(addr) || /^fe80::1$/i.test(addr); + } + + // Handle IPv4 addresses (including non-standard formats) + // First, try to normalize the address + const ipl = ip.normalizeToLong(addr); + + if (ipl >= 0) { + // Successfully normalized - check if it's in 127.0.0.0/8 + // 127.0.0.0/8 = 2130706432 to 2147483647 + // (127 << 24) = 2130706432, (128 << 24) - 1 = 2147483647 + if (ipl >= 2130706432 && ipl <= 2147483647) { + return true; + } + // Also check for 0.0.0.0 (sometimes used as loopback equivalent) + if (ipl === 0) { + return true; + } + return false; } - return /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/ - .test(addr) + // Fallback for addresses that couldn't be normalized + // (shouldn't happen with valid addresses, but keep for safety) + return /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/.test(addr) || /^0177\./.test(addr) || /^0x7f\./i.test(addr) || /^fe80::1$/i.test(addr) From 08af2a2f09897a865f64aa44dddc29c216277c26 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan <60182103+abhu85@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:41:39 +0000 Subject: [PATCH 2/2] test: add comprehensive SSRF bypass pattern tests (CVE-2024-29415) Add test cases for all known SSRF bypass patterns: - Null route "0" (Issue #160) - 32-bit octal format "017700000001" (Issue #162, CVE-2025-59436) - Short-form IPs (127.1, 127.0.1) - IPv6 loopback variations - IPv6-mapped IPv4 addresses in hex notation - Standard private range verification These tests document the security-critical behavior and ensure bypass patterns are correctly identified as private/loopback. --- test/api-test.js | 255 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) diff --git a/test/api-test.js b/test/api-test.js index 0db838d..0720c63 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -506,4 +506,259 @@ describe('IP library for node.js', () => { it('should return false for "192.168.1.1"', () => { assert.equal(ip.isLoopback('192.168.1.1'), false); }); + + // ========================================================================== + // SSRF Bypass Pattern Tests (CVE-2024-29415, CVE-2025-59436) + // ========================================================================== + // These tests verify that known SSRF bypass patterns are correctly + // identified as private/loopback addresses to prevent SSRF attacks. + // ========================================================================== + + describe('SSRF Bypass Prevention (CVE-2024-29415)', () => { + describe('Null route "0" bypass (Issue #160)', () => { + // The "0" input resolves to 0.0.0.0 which is a reserved address + // and should be treated as private to prevent SSRF attacks. + // Attack vector: http://0:3000/admin -> accesses localhost + + it('should identify "0" as private (null route)', () => { + assert.equal(ip.isPrivate('0'), true); + assert.equal(ip.isPublic('0'), false); + }); + + it('should identify "0" as loopback', () => { + assert.equal(ip.isLoopback('0'), true); + }); + + it('should identify "0.0.0.0" as private', () => { + assert.equal(ip.isPrivate('0.0.0.0'), true); + assert.equal(ip.isPublic('0.0.0.0'), false); + }); + }); + + describe('32-bit octal format bypass (Issue #162, CVE-2025-59436)', () => { + // The input "017700000001" is a 32-bit octal representation + // that equals 127.0.0.1 (localhost). + // parseInt("017700000001", 8) = 2130706433 = 127.0.0.1 + // Attack vector: http://017700000001:3000/admin -> accesses localhost + + it('should identify "017700000001" as private (32-bit octal localhost)', () => { + assert.equal(ip.isPrivate('017700000001'), true); + assert.equal(ip.isPublic('017700000001'), false); + }); + + it('should identify "017700000001" as loopback', () => { + assert.equal(ip.isLoopback('017700000001'), true); + }); + + it('should identify "0x7f000001" as private (32-bit hex localhost)', () => { + assert.equal(ip.isPrivate('0x7f000001'), true); + assert.equal(ip.isPublic('0x7f000001'), false); + }); + + it('should identify "0x7f000001" as loopback', () => { + assert.equal(ip.isLoopback('0x7f000001'), true); + }); + }); + + describe('Short-form IP bypass (Issue #150)', () => { + // Short-form IPs like "127.1" are interpreted as "127.0.0.1" + // by network stacks, allowing SSRF bypass. + // Attack vector: http://127.1:3000/admin -> accesses localhost + + it('should identify "127.1" as private', () => { + assert.equal(ip.isPrivate('127.1'), true); + assert.equal(ip.isPublic('127.1'), false); + }); + + it('should identify "127.1" as loopback', () => { + assert.equal(ip.isLoopback('127.1'), true); + }); + + it('should identify "127.0.1" as private', () => { + assert.equal(ip.isPrivate('127.0.1'), true); + assert.equal(ip.isPublic('127.0.1'), false); + }); + + it('should identify "127.0.1" as loopback', () => { + assert.equal(ip.isLoopback('127.0.1'), true); + }); + + it('should identify "10.1" as private (10.0.0.1)', () => { + assert.equal(ip.isPrivate('10.1'), true); + assert.equal(ip.isPublic('10.1'), false); + }); + + it('should identify "192.168.1" as private (192.168.0.1)', () => { + assert.equal(ip.isPrivate('192.168.1'), true); + assert.equal(ip.isPublic('192.168.1'), false); + }); + }); + + describe('Octal notation bypass (Issue #150)', () => { + // Octal notation like "0177.0.0.1" equals 127.0.0.1 + // Attack vector: http://0177.0.0.1:3000/admin -> accesses localhost + + it('should identify "0177.0.0.1" as private', () => { + assert.equal(ip.isPrivate('0177.0.0.1'), true); + assert.equal(ip.isPublic('0177.0.0.1'), false); + }); + + it('should identify "0177.1" as private', () => { + assert.equal(ip.isPrivate('0177.1'), true); + assert.equal(ip.isPublic('0177.1'), false); + }); + + it('should identify "012.1.2.3" as private (octal 10.x.x.x)', () => { + // 012 in octal = 10 in decimal + assert.equal(ip.isPrivate('012.1.2.3'), true); + assert.equal(ip.isPublic('012.1.2.3'), false); + }); + }); + + describe('Hexadecimal notation bypass (Issue #150)', () => { + // Hexadecimal notation like "0x7f.0.0.1" equals 127.0.0.1 + // Attack vector: http://0x7f.0.0.1:3000/admin -> accesses localhost + + it('should identify "0x7f.0.0.1" as private', () => { + assert.equal(ip.isPrivate('0x7f.0.0.1'), true); + assert.equal(ip.isPublic('0x7f.0.0.1'), false); + }); + + it('should identify "0x7f.1" as private', () => { + assert.equal(ip.isPrivate('0x7f.1'), true); + assert.equal(ip.isPublic('0x7f.1'), false); + }); + + it('should identify "0x0a.0.0.1" as private (hex 10.0.0.1)', () => { + assert.equal(ip.isPrivate('0x0a.0.0.1'), true); + assert.equal(ip.isPublic('0x0a.0.0.1'), false); + }); + }); + + describe('IPv6 loopback variations (Issue #150)', () => { + // Various IPv6 representations of loopback address + // Attack vector: http://[::1]:3000/admin -> accesses localhost + + it('should identify "::1" as private', () => { + assert.equal(ip.isPrivate('::1'), true); + assert.equal(ip.isPublic('::1'), false); + }); + + it('should identify "0:0:0:0:0:0:0:1" as private (expanded ::1)', () => { + assert.equal(ip.isPrivate('0:0:0:0:0:0:0:1'), true); + assert.equal(ip.isPublic('0:0:0:0:0:0:0:1'), false); + }); + + it('should identify "0000:0000:0000:0000:0000:0000:0000:0001" as private', () => { + assert.equal(ip.isPrivate('0000:0000:0000:0000:0000:0000:0000:0001'), true); + assert.equal(ip.isPublic('0000:0000:0000:0000:0000:0000:0000:0001'), false); + }); + + it('should identify "::1" as loopback', () => { + assert.equal(ip.isLoopback('::1'), true); + }); + + it('should identify "0:0:0:0:0:0:0:1" as loopback', () => { + assert.equal(ip.isLoopback('0:0:0:0:0:0:0:1'), true); + }); + + it('should identify "::" as private (unspecified address)', () => { + assert.equal(ip.isPrivate('::'), true); + assert.equal(ip.isPublic('::'), false); + }); + }); + + describe('IPv6-mapped IPv4 bypass (Issue #150, #122)', () => { + // IPv6-mapped IPv4 addresses like "::ffff:127.0.0.1" contain + // an embedded IPv4 address that should be checked. + // Attack vector: http://[::ffff:127.0.0.1]:3000/admin -> accesses localhost + + it('should identify "::ffff:127.0.0.1" as private (dot notation)', () => { + assert.equal(ip.isPrivate('::ffff:127.0.0.1'), true); + assert.equal(ip.isPublic('::ffff:127.0.0.1'), false); + }); + + it('should identify "::ffff:7f00:1" as private (hex notation)', () => { + assert.equal(ip.isPrivate('::ffff:7f00:1'), true); + assert.equal(ip.isPublic('::ffff:7f00:1'), false); + }); + + it('should identify "::ffff:7f00:0001" as private', () => { + assert.equal(ip.isPrivate('::ffff:7f00:0001'), true); + assert.equal(ip.isPublic('::ffff:7f00:0001'), false); + }); + + it('should identify "0:0:0:0:0:ffff:127.0.0.1" as private (expanded)', () => { + assert.equal(ip.isPrivate('0:0:0:0:0:ffff:127.0.0.1'), true); + assert.equal(ip.isPublic('0:0:0:0:0:ffff:127.0.0.1'), false); + }); + + it('should identify "::ffff:10.0.0.1" as private', () => { + assert.equal(ip.isPrivate('::ffff:10.0.0.1'), true); + assert.equal(ip.isPublic('::ffff:10.0.0.1'), false); + }); + + it('should identify "::ffff:192.168.1.1" as private', () => { + assert.equal(ip.isPrivate('::ffff:192.168.1.1'), true); + assert.equal(ip.isPublic('::ffff:192.168.1.1'), false); + }); + + it('should identify "::ffff:172.16.0.1" as private', () => { + assert.equal(ip.isPrivate('::ffff:172.16.0.1'), true); + assert.equal(ip.isPublic('::ffff:172.16.0.1'), false); + }); + + it('should identify "::FFFF:127.0.0.1" as private (uppercase)', () => { + assert.equal(ip.isPrivate('::FFFF:127.0.0.1'), true); + assert.equal(ip.isPublic('::FFFF:127.0.0.1'), false); + }); + + it('should correctly identify public IPv6-mapped addresses', () => { + // 8.8.8.8 is Google DNS - a public address + assert.equal(ip.isPrivate('::ffff:8.8.8.8'), false); + assert.equal(ip.isPublic('::ffff:8.8.8.8'), true); + }); + }); + + describe('Decimal/Long integer format bypass', () => { + // Long integer format like "2130706433" equals 127.0.0.1 + // Attack vector: http://2130706433:3000/admin -> accesses localhost + + it('should identify "2130706433" as loopback (127.0.0.1 as long)', () => { + assert.equal(ip.isLoopback('2130706433'), true); + }); + + it('should identify "167772161" as private (10.0.0.1 as long)', () => { + // 10.0.0.1 = (10 << 24) + 1 = 167772161 + assert.equal(ip.isPrivate('167772161'), true); + assert.equal(ip.isPublic('167772161'), false); + }); + + it('should identify public addresses in long format correctly', () => { + // 8.8.8.8 = (8 << 24) + (8 << 16) + (8 << 8) + 8 = 134744072 + assert.equal(ip.isPrivate('134744072'), false); + assert.equal(ip.isPublic('134744072'), true); + }); + }); + + describe('Edge cases and regression tests', () => { + it('should still correctly identify standard public IPs', () => { + assert.equal(ip.isPublic('8.8.8.8'), true); + assert.equal(ip.isPublic('1.1.1.1'), true); + assert.equal(ip.isPublic('208.67.222.222'), true); + }); + + it('should still correctly identify standard private IPs', () => { + assert.equal(ip.isPrivate('10.0.0.1'), true); + assert.equal(ip.isPrivate('172.16.0.1'), true); + assert.equal(ip.isPrivate('192.168.0.1'), true); + assert.equal(ip.isPrivate('127.0.0.1'), true); + }); + + it('should throw on invalid addresses', () => { + assert.throws(() => ip.isPrivate('invalid'), /invalid/i); + assert.throws(() => ip.isPrivate('256.0.0.1'), /invalid/i); + }); + }); + }); });