From ad24c94772dd5747d1ab40c40c2a4377235ee78a Mon Sep 17 00:00:00 2001 From: aayush598 Date: Sat, 21 Feb 2026 18:59:40 +0530 Subject: [PATCH] fix(security): allow localhost HTTP without weakening SSRF protections --- .../core/security/input-validation.server.ts | 16 ++++- .../core/security/input-validation.test.ts | 61 +++++++++++++++---- .../sim/lib/core/security/input-validation.ts | 30 ++++----- 3 files changed, 80 insertions(+), 27 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 2a912240cb..5515f410f3 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -65,9 +65,21 @@ export async function validateUrlWithDNS( const hostname = parsedUrl.hostname try { - const { address } = await dns.lookup(hostname) + const lookupHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const { address } = await dns.lookup(lookupHostname, { verbatim: true }) - if (isPrivateOrReservedIP(address)) { + const hostnameLower = hostname.toLowerCase() + + let isLocalhost = hostnameLower === 'localhost' + + if (ipaddr.isValid(address)) { + const processedIP = ipaddr.process(address).toString() + if (processedIP === '127.0.0.1' || processedIP === '::1') { + isLocalhost = true + } + } + + if (isPrivateOrReservedIP(address) && !isLocalhost) { logger.warn('URL resolves to blocked IP address', { paramName, hostname, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index a2b842d40e..ebe65983dc 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -569,10 +569,28 @@ describe('validateUrlWithDNS', () => { expect(result.error).toContain('https://') }) - it('should reject localhost URLs', async () => { + it('should accept https localhost URLs', async () => { const result = await validateUrlWithDNS('https://localhost/api') - expect(result.isValid).toBe(false) - expect(result.error).toContain('localhost') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() + }) + + it('should accept http localhost URLs', async () => { + const result = await validateUrlWithDNS('http://localhost/api') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() + }) + + it('should accept IPv4 loopback URLs', async () => { + const result = await validateUrlWithDNS('http://127.0.0.1/api') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() + }) + + it('should accept IPv6 loopback URLs', async () => { + const result = await validateUrlWithDNS('http://[::1]/api') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() }) it('should reject private IP URLs', async () => { @@ -899,16 +917,37 @@ describe('validateExternalUrl', () => { expect(result.error).toContain('valid URL') }) - it.concurrent('should reject localhost', () => { + }) + + describe('localhost and loopback addresses', () => { + it.concurrent('should accept https localhost', () => { const result = validateExternalUrl('https://localhost/api') - expect(result.isValid).toBe(false) - expect(result.error).toContain('localhost') + expect(result.isValid).toBe(true) }) - it.concurrent('should reject 127.0.0.1', () => { + it.concurrent('should accept http localhost', () => { + const result = validateExternalUrl('http://localhost/api') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept https 127.0.0.1', () => { const result = validateExternalUrl('https://127.0.0.1/api') - expect(result.isValid).toBe(false) - expect(result.error).toContain('private IP') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept http 127.0.0.1', () => { + const result = validateExternalUrl('http://127.0.0.1/api') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept https IPv6 loopback', () => { + const result = validateExternalUrl('https://[::1]/api') + expect(result.isValid).toBe(true) + }) + + it.concurrent('should accept http IPv6 loopback', () => { + const result = validateExternalUrl('http://[::1]/api') + expect(result.isValid).toBe(true) }) it.concurrent('should reject 0.0.0.0', () => { @@ -989,9 +1028,9 @@ describe('validateImageUrl', () => { expect(result.isValid).toBe(true) }) - it.concurrent('should reject localhost URLs', () => { + it.concurrent('should accept localhost URLs', () => { const result = validateImageUrl('https://localhost/image.png') - expect(result.isValid).toBe(false) + expect(result.isValid).toBe(true) }) it.concurrent('should use imageUrl as default param name', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e156c7ad44..1f0dd2c8a0 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -664,28 +664,30 @@ export function validateExternalUrl( } } - // Only allow https protocol - if (parsedUrl.protocol !== 'https:') { - return { - isValid: false, - error: `${paramName} must use https:// protocol`, + const protocol = parsedUrl.protocol + const hostname = parsedUrl.hostname.toLowerCase() + + const cleanHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + + let isLocalhost = cleanHostname === 'localhost' + if (ipaddr.isValid(cleanHostname)) { + const processedIP = ipaddr.process(cleanHostname).toString() + if (processedIP === '127.0.0.1' || processedIP === '::1') { + isLocalhost = true } } - // Block private IP ranges and localhost - const hostname = parsedUrl.hostname.toLowerCase() - - // Block localhost - if (hostname === 'localhost') { + // Require HTTPS except for localhost development + if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) { return { isValid: false, - error: `${paramName} cannot point to localhost`, + error: `${paramName} must use https:// protocol`, } } - // Use ipaddr.js to check if hostname is an IP and if it's private/reserved - if (ipaddr.isValid(hostname)) { - if (isPrivateOrReservedIP(hostname)) { + // Block private/reserved IPs while allowing loopback addresses for local development. + if (!isLocalhost && ipaddr.isValid(cleanHostname)) { + if (isPrivateOrReservedIP(cleanHostname)) { return { isValid: false, error: `${paramName} cannot point to private IP addresses`,