From 816b4a0da5dcc6e95cdbac6b99fcc27ee59d3bcb Mon Sep 17 00:00:00 2001 From: aayush598 Date: Sat, 21 Feb 2026 18:59:40 +0530 Subject: [PATCH 1/4] 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`, From e298899fcb026acd408d454d1fd028429fbca9c5 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 13:55:19 -0800 Subject: [PATCH 2/4] fix(security): remove extraneous comments and fix failing SSRF test --- .../app/api/function/execute/route.test.ts | 2 +- .../core/security/input-validation.server.ts | 7 +++-- .../core/security/input-validation.test.ts | 1 - .../sim/lib/core/security/input-validation.ts | 27 +++++-------------- 4 files changed, 10 insertions(+), 27 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 084197e597..e73e30e350 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -211,7 +211,7 @@ describe('Function Execute API Route', () => { it.concurrent('should block SSRF attacks through secure fetch wrapper', async () => { expect(validateProxyUrl('http://169.254.169.254/latest/meta-data/').isValid).toBe(false) - expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(false) + expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(true) expect(validateProxyUrl('http://192.168.1.1/config').isValid).toBe(false) expect(validateProxyUrl('http://10.0.0.1/internal').isValid).toBe(false) }) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 5515f410f3..19c4ad09cb 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -65,7 +65,8 @@ export async function validateUrlWithDNS( const hostname = parsedUrl.hostname try { - const lookupHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const lookupHostname = + hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname const { address } = await dns.lookup(lookupHostname, { verbatim: true }) const hostnameLower = hostname.toLowerCase() @@ -201,8 +202,6 @@ export async function secureFetchWithPinnedIP( const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions) - // Remove accept-encoding since Node.js http/https doesn't auto-decompress - // Headers are lowercase due to Web Headers API normalization in executeToolRequest const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {} const requestOptions: http.RequestOptions = { @@ -212,7 +211,7 @@ export async function secureFetchWithPinnedIP( method: options.method || 'GET', headers: sanitizedHeaders, agent, - timeout: options.timeout || 300000, // Default 5 minutes + timeout: options.timeout || 300000, } const protocol = isHttps ? https : http diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index ebe65983dc..3098c7294f 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -916,7 +916,6 @@ describe('validateExternalUrl', () => { expect(result.isValid).toBe(false) expect(result.error).toContain('valid URL') }) - }) describe('localhost and loopback addresses', () => { diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 1f0dd2c8a0..06bc41b069 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -89,9 +89,9 @@ export function validatePathSegment( const pathTraversalPatterns = [ '..', './', - '.\\.', // Windows path traversal - '%2e%2e', // URL encoded .. - '%252e%252e', // Double URL encoded .. + '.\\.', + '%2e%2e', + '%252e%252e', '..%2f', '..%5c', '%2e%2e%2f', @@ -391,7 +391,6 @@ export function validateHostname( const lowerHostname = hostname.toLowerCase() - // Block localhost if (lowerHostname === 'localhost') { logger.warn('Hostname is localhost', { paramName }) return { @@ -400,7 +399,6 @@ export function validateHostname( } } - // 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', { @@ -414,7 +412,6 @@ export function validateHostname( } } - // Basic hostname format validation const hostnamePattern = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i @@ -460,10 +457,7 @@ export function validateFileExtension( } } - // Remove leading dot if present const ext = extension.startsWith('.') ? extension.slice(1) : extension - - // Normalize to lowercase const normalizedExt = ext.toLowerCase() if (!allowedExtensions.map((e) => e.toLowerCase()).includes(normalizedExt)) { @@ -515,7 +509,6 @@ export function validateMicrosoftGraphId( } } - // Check for path traversal patterns (../) const pathTraversalPatterns = [ '../', '..\\', @@ -525,7 +518,7 @@ export function validateMicrosoftGraphId( '%2e%2e%5c', '%2e%2e\\', '..%5c', - '%252e%252e%252f', // double encoded + '%252e%252e%252f', ] const lowerValue = value.toLowerCase() @@ -542,7 +535,6 @@ export function validateMicrosoftGraphId( } } - // Check for control characters and null bytes if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) { logger.warn('Control characters in Microsoft Graph ID', { paramName }) return { @@ -551,7 +543,6 @@ export function validateMicrosoftGraphId( } } - // Check for newlines (which could be used for header injection) if (value.includes('\n') || value.includes('\r')) { return { isValid: false, @@ -559,8 +550,6 @@ export function validateMicrosoftGraphId( } } - // Microsoft Graph IDs can contain many characters, but not suspicious patterns - // We've blocked path traversal, so allow the rest return { isValid: true, sanitized: value } } @@ -583,7 +572,6 @@ export function validateJiraCloudId( value: string | null | undefined, paramName = 'cloudId' ): ValidationResult { - // Jira cloud IDs are alphanumeric with hyphens (UUID-like) return validatePathSegment(value, { paramName, allowHyphens: true, @@ -612,7 +600,6 @@ export function validateJiraIssueKey( value: string | null | undefined, paramName = 'issueKey' ): ValidationResult { - // Jira issue keys: letters, numbers, hyphens (PROJECT-123 format) return validatePathSegment(value, { paramName, allowHyphens: true, @@ -653,7 +640,6 @@ export function validateExternalUrl( } } - // Must be a valid URL let parsedUrl: URL try { parsedUrl = new URL(url) @@ -667,7 +653,8 @@ export function validateExternalUrl( const protocol = parsedUrl.protocol const hostname = parsedUrl.hostname.toLowerCase() - const cleanHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + const cleanHostname = + hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname let isLocalhost = cleanHostname === 'localhost' if (ipaddr.isValid(cleanHostname)) { @@ -677,7 +664,6 @@ export function validateExternalUrl( } } - // Require HTTPS except for localhost development if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) { return { isValid: false, @@ -685,7 +671,6 @@ export function validateExternalUrl( } } - // Block private/reserved IPs while allowing loopback addresses for local development. if (!isLocalhost && ipaddr.isValid(cleanHostname)) { if (isPrivateOrReservedIP(cleanHostname)) { return { From 7c933460f7a2944844460f9d9a5c896a9fac2366 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 14:35:58 -0800 Subject: [PATCH 3/4] fix(security): derive isLocalhost from hostname not resolved IP in validateUrlWithDNS --- .../core/security/input-validation.server.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 19c4ad09cb..642e15c374 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -64,21 +64,22 @@ export async function validateUrlWithDNS( const parsedUrl = new URL(url!) const hostname = parsedUrl.hostname - try { - const lookupHostname = - hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname - const { address } = await dns.lookup(lookupHostname, { verbatim: true }) - - 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 - } + const hostnameLower = hostname.toLowerCase() + const cleanHostname = + hostnameLower.startsWith('[') && hostnameLower.endsWith(']') + ? hostnameLower.slice(1, -1) + : hostnameLower + + let isLocalhost = cleanHostname === 'localhost' + if (ipaddr.isValid(cleanHostname)) { + const processedIP = ipaddr.process(cleanHostname).toString() + if (processedIP === '127.0.0.1' || processedIP === '::1') { + isLocalhost = true } + } + + try { + const { address } = await dns.lookup(cleanHostname, { verbatim: true }) if (isPrivateOrReservedIP(address) && !isLocalhost) { logger.warn('URL resolves to blocked IP address', { From 9516dad254b6f4174c27b6c89cfce1d9b88b91e7 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 22 Feb 2026 14:45:11 -0800 Subject: [PATCH 4/4] fix(security): verify resolved IP is loopback when hostname is localhost in validateUrlWithDNS --- apps/sim/lib/core/security/input-validation.server.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 642e15c374..7253ab2898 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -81,7 +81,14 @@ export async function validateUrlWithDNS( try { const { address } = await dns.lookup(cleanHostname, { verbatim: true }) - if (isPrivateOrReservedIP(address) && !isLocalhost) { + const resolvedIsLoopback = + ipaddr.isValid(address) && + (() => { + const ip = ipaddr.process(address).toString() + return ip === '127.0.0.1' || ip === '::1' + })() + + if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback)) { logger.warn('URL resolves to blocked IP address', { paramName, hostname,