From 785e3a7601fd5851e0197fa6d4d130fa56c50939 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 20 Jan 2026 17:37:57 -0800 Subject: [PATCH] use ipaddr --- .../core/security/input-validation.test.ts | 4 +- .../sim/lib/core/security/input-validation.ts | 112 ++++++------------ apps/sim/package.json | 1 + bun.lock | 6 +- 4 files changed, 42 insertions(+), 81 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 7be54952d..c1979c1e3 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -906,13 +906,13 @@ describe('validateExternalUrl', () => { it.concurrent('should reject 127.0.0.1', () => { const result = validateExternalUrl('https://127.0.0.1/api') expect(result.isValid).toBe(false) - expect(result.error).toContain('localhost') + expect(result.error).toContain('private IP') }) it.concurrent('should reject 0.0.0.0', () => { const result = validateExternalUrl('https://0.0.0.0/api') expect(result.isValid).toBe(false) - expect(result.error).toContain('localhost') + expect(result.error).toContain('private IP') }) }) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 1dbb16be5..f15b2412e 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -2,6 +2,7 @@ import dns from 'dns/promises' import http from 'http' import https from 'https' import { createLogger } from '@sim/logger' +import * as ipaddr from 'ipaddr.js' const logger = createLogger('InputValidation') @@ -404,42 +405,20 @@ export function validateHostname( } } - // Import the blocked IP ranges from url-validation - const BLOCKED_IP_RANGES = [ - // Private IPv4 ranges (RFC 1918) - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[01])\./, - /^192\.168\./, - - // Loopback addresses - /^127\./, - /^localhost$/i, - - // Link-local addresses (RFC 3927) - /^169\.254\./, - - // Cloud metadata endpoints - /^169\.254\.169\.254$/, - - // Broadcast and other reserved ranges - /^0\./, - /^224\./, - /^240\./, - /^255\./, - - // IPv6 loopback and link-local - /^::1$/, - /^fe80:/i, - /^::ffff:127\./i, - /^::ffff:10\./i, - /^::ffff:172\.(1[6-9]|2[0-9]|3[01])\./i, - /^::ffff:192\.168\./i, - ] - const lowerHostname = hostname.toLowerCase() - for (const pattern of BLOCKED_IP_RANGES) { - if (pattern.test(lowerHostname)) { + // Block localhost + if (lowerHostname === 'localhost') { + logger.warn('Hostname is localhost', { paramName }) + return { + isValid: false, + error: `${paramName} cannot be a private IP address or localhost`, + } + } + + // Use ipaddr.js to check if hostname is an IP and if it's private/reserved + if (ipaddr.isValid(lowerHostname)) { + if (isPrivateOrReservedIP(lowerHostname)) { logger.warn('Hostname matches blocked IP range', { paramName, hostname: hostname.substring(0, 100), @@ -712,33 +691,17 @@ export function validateExternalUrl( // Block private IP ranges and localhost const hostname = parsedUrl.hostname.toLowerCase() - // Block localhost variations - if ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '::1' || - hostname.startsWith('127.') || - hostname === '0.0.0.0' - ) { + // Block localhost + if (hostname === 'localhost') { return { isValid: false, error: `${paramName} cannot point to localhost`, } } - // Block private IP ranges - const privateIpPatterns = [ - /^10\./, - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, - /^192\.168\./, - /^169\.254\./, // Link-local - /^fe80:/i, // IPv6 link-local - /^fc00:/i, // IPv6 unique local - /^fd00:/i, // IPv6 unique local - ] - - for (const pattern of privateIpPatterns) { - if (pattern.test(hostname)) { + // Use ipaddr.js to check if hostname is an IP and if it's private/reserved + if (ipaddr.isValid(hostname)) { + if (isPrivateOrReservedIP(hostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`, @@ -793,30 +756,25 @@ export function validateProxyUrl( /** * Checks if an IP address is private or reserved (not routable on the public internet) + * Uses ipaddr.js for robust handling of all IP formats including: + * - Octal notation (0177.0.0.1) + * - Hex notation (0x7f000001) + * - IPv4-mapped IPv6 (::ffff:127.0.0.1) + * - Various edge cases that regex patterns miss */ function isPrivateOrReservedIP(ip: string): boolean { - const patterns = [ - /^127\./, // Loopback - /^10\./, // Private Class A - /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B - /^192\.168\./, // Private Class C - /^169\.254\./, // Link-local - /^0\./, // Current network - /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // Carrier-grade NAT - /^192\.0\.0\./, // IETF Protocol Assignments - /^192\.0\.2\./, // TEST-NET-1 - /^198\.51\.100\./, // TEST-NET-2 - /^203\.0\.113\./, // TEST-NET-3 - /^224\./, // Multicast - /^240\./, // Reserved - /^255\./, // Broadcast - /^::1$/, // IPv6 loopback - /^fe80:/i, // IPv6 link-local - /^fc00:/i, // IPv6 unique local - /^fd00:/i, // IPv6 unique local - /^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped IPv6 - ] - return patterns.some((pattern) => pattern.test(ip)) + try { + if (!ipaddr.isValid(ip)) { + return true + } + + const addr = ipaddr.process(ip) + const range = addr.range() + + return range !== 'unicast' + } catch { + return true + } } /** diff --git a/apps/sim/package.json b/apps/sim/package.json index 3213602ee..2c20905b9 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -108,6 +108,7 @@ "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", + "ipaddr.js": "2.3.0", "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", diff --git a/bun.lock b/bun.lock index 7de9501f3..44478f6a0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "simstudio", @@ -139,6 +138,7 @@ "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", + "ipaddr.js": "2.3.0", "isolated-vm": "6.0.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", @@ -2348,7 +2348,7 @@ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -4100,6 +4100,8 @@ "protobufjs/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "puppeteer-core/devtools-protocol": ["devtools-protocol@0.0.1312386", "", {}, "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA=="],