Skip to content

Commit b595273

Browse files
authored
fix(vuln): fix dns rebinding/ssrf vulnerability (#2316)
1 parent 193a15a commit b595273

File tree

4 files changed

+202
-6
lines changed

4 files changed

+202
-6
lines changed

apps/sim/app/api/files/parse/route.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from 'path'
55
import binaryExtensionsList from 'binary-extensions'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { checkHybridAuth } from '@/lib/auth/hybrid'
8-
import { validateExternalUrl } from '@/lib/core/security/input-validation'
8+
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
99
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
1010
import { createLogger } from '@/lib/logs/console/logger'
1111
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
@@ -270,7 +270,7 @@ async function handleExternalUrl(
270270
logger.info('Fetching external URL:', url)
271271
logger.info('WorkspaceId for URL save:', workspaceId)
272272

273-
const urlValidation = validateExternalUrl(url, 'fileUrl')
273+
const urlValidation = await validateUrlWithDNS(url, 'fileUrl')
274274
if (!urlValidation.isValid) {
275275
logger.warn(`Blocked external URL request: ${urlValidation.error}`)
276276
return {
@@ -346,8 +346,12 @@ async function handleExternalUrl(
346346
}
347347
}
348348

349-
const response = await fetch(url, {
349+
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
350+
const response = await fetch(pinnedUrl, {
350351
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
352+
headers: {
353+
Host: urlValidation.originalHostname!,
354+
},
351355
})
352356
if (!response.ok) {
353357
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)

apps/sim/app/api/proxy/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod'
44
import { checkHybridAuth } from '@/lib/auth/hybrid'
55
import { generateInternalToken } from '@/lib/auth/internal'
66
import { isDev } from '@/lib/core/config/environment'
7-
import { validateProxyUrl } from '@/lib/core/security/input-validation'
7+
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { getBaseUrl } from '@/lib/core/utils/urls'
1010
import { createLogger } from '@/lib/logs/console/logger'
@@ -173,7 +173,7 @@ export async function GET(request: Request) {
173173
return createErrorResponse("Missing 'url' parameter", 400)
174174
}
175175

176-
const urlValidation = validateProxyUrl(targetUrl)
176+
const urlValidation = await validateUrlWithDNS(targetUrl)
177177
if (!urlValidation.isValid) {
178178
logger.warn(`[${requestId}] Blocked proxy request`, {
179179
url: targetUrl.substring(0, 100),
@@ -211,11 +211,13 @@ export async function GET(request: Request) {
211211
logger.info(`[${requestId}] Proxying ${method} request to: ${targetUrl}`)
212212

213213
try {
214-
const response = await fetch(targetUrl, {
214+
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
215+
const response = await fetch(pinnedUrl, {
215216
method: method,
216217
headers: {
217218
...getProxyHeaders(),
218219
...customHeaders,
220+
Host: urlValidation.originalHostname!,
219221
},
220222
body: body || undefined,
221223
})

apps/sim/lib/core/security/input-validation.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
createPinnedUrl,
34
sanitizeForLogging,
45
validateAlphanumericId,
56
validateEnum,
67
validateFileExtension,
78
validateHostname,
89
validateNumericId,
910
validatePathSegment,
11+
validateUrlWithDNS,
1012
validateUUID,
1113
} from '@/lib/core/security/input-validation'
1214

@@ -588,3 +590,83 @@ describe('sanitizeForLogging', () => {
588590
expect(result).toBe(input)
589591
})
590592
})
593+
594+
describe('validateUrlWithDNS', () => {
595+
describe('basic validation', () => {
596+
it('should reject invalid URLs', async () => {
597+
const result = await validateUrlWithDNS('not-a-url')
598+
expect(result.isValid).toBe(false)
599+
expect(result.error).toContain('valid URL')
600+
})
601+
602+
it('should reject http:// URLs', async () => {
603+
const result = await validateUrlWithDNS('http://example.com')
604+
expect(result.isValid).toBe(false)
605+
expect(result.error).toContain('https://')
606+
})
607+
608+
it('should reject localhost URLs', async () => {
609+
const result = await validateUrlWithDNS('https://localhost/api')
610+
expect(result.isValid).toBe(false)
611+
expect(result.error).toContain('localhost')
612+
})
613+
614+
it('should reject private IP URLs', async () => {
615+
const result = await validateUrlWithDNS('https://192.168.1.1/api')
616+
expect(result.isValid).toBe(false)
617+
expect(result.error).toContain('private IP')
618+
})
619+
620+
it('should reject null', async () => {
621+
const result = await validateUrlWithDNS(null)
622+
expect(result.isValid).toBe(false)
623+
})
624+
625+
it('should reject empty string', async () => {
626+
const result = await validateUrlWithDNS('')
627+
expect(result.isValid).toBe(false)
628+
})
629+
})
630+
631+
describe('DNS resolution', () => {
632+
it('should accept valid public URLs and return resolved IP', async () => {
633+
const result = await validateUrlWithDNS('https://example.com')
634+
expect(result.isValid).toBe(true)
635+
expect(result.resolvedIP).toBeDefined()
636+
expect(result.originalHostname).toBe('example.com')
637+
})
638+
639+
it('should reject URLs that resolve to private IPs', async () => {
640+
const result = await validateUrlWithDNS('https://localhost.localdomain')
641+
expect(result.isValid).toBe(false)
642+
})
643+
644+
it('should reject unresolvable hostnames', async () => {
645+
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
646+
expect(result.isValid).toBe(false)
647+
expect(result.error).toContain('could not be resolved')
648+
})
649+
})
650+
})
651+
652+
describe('createPinnedUrl', () => {
653+
it('should replace hostname with IP', () => {
654+
const result = createPinnedUrl('https://example.com/api/data', '93.184.216.34')
655+
expect(result).toBe('https://93.184.216.34/api/data')
656+
})
657+
658+
it('should preserve port if specified', () => {
659+
const result = createPinnedUrl('https://example.com:8443/api', '93.184.216.34')
660+
expect(result).toBe('https://93.184.216.34:8443/api')
661+
})
662+
663+
it('should preserve query string', () => {
664+
const result = createPinnedUrl('https://example.com/api?foo=bar&baz=qux', '93.184.216.34')
665+
expect(result).toBe('https://93.184.216.34/api?foo=bar&baz=qux')
666+
})
667+
668+
it('should preserve path', () => {
669+
const result = createPinnedUrl('https://example.com/a/b/c/d', '93.184.216.34')
670+
expect(result).toBe('https://93.184.216.34/a/b/c/d')
671+
})
672+
})

apps/sim/lib/core/security/input-validation.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import dns from 'dns/promises'
12
import { createLogger } from '@/lib/logs/console/logger'
23

34
const logger = createLogger('InputValidation')
@@ -850,3 +851,110 @@ export function validateProxyUrl(
850851
): ValidationResult {
851852
return validateExternalUrl(url, paramName)
852853
}
854+
855+
/**
856+
* Checks if an IP address is private or reserved (not routable on the public internet)
857+
*/
858+
function isPrivateOrReservedIP(ip: string): boolean {
859+
const patterns = [
860+
/^127\./, // Loopback
861+
/^10\./, // Private Class A
862+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Private Class B
863+
/^192\.168\./, // Private Class C
864+
/^169\.254\./, // Link-local
865+
/^0\./, // Current network
866+
/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // Carrier-grade NAT
867+
/^192\.0\.0\./, // IETF Protocol Assignments
868+
/^192\.0\.2\./, // TEST-NET-1
869+
/^198\.51\.100\./, // TEST-NET-2
870+
/^203\.0\.113\./, // TEST-NET-3
871+
/^224\./, // Multicast
872+
/^240\./, // Reserved
873+
/^255\./, // Broadcast
874+
/^::1$/, // IPv6 loopback
875+
/^fe80:/i, // IPv6 link-local
876+
/^fc00:/i, // IPv6 unique local
877+
/^fd00:/i, // IPv6 unique local
878+
/^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped IPv6
879+
]
880+
return patterns.some((pattern) => pattern.test(ip))
881+
}
882+
883+
/**
884+
* Result type for async URL validation with resolved IP
885+
*/
886+
export interface AsyncValidationResult extends ValidationResult {
887+
resolvedIP?: string
888+
originalHostname?: string
889+
}
890+
891+
/**
892+
* Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding
893+
*
894+
* This function:
895+
* 1. Performs basic URL validation (protocol, format)
896+
* 2. Resolves the hostname to an IP address
897+
* 3. Validates the resolved IP is not private/reserved
898+
* 4. Returns the resolved IP for use in the actual request
899+
*
900+
* @param url - The URL to validate
901+
* @param paramName - Name of the parameter for error messages
902+
* @returns AsyncValidationResult with resolved IP for DNS pinning
903+
*/
904+
export async function validateUrlWithDNS(
905+
url: string | null | undefined,
906+
paramName = 'url'
907+
): Promise<AsyncValidationResult> {
908+
const basicValidation = validateExternalUrl(url, paramName)
909+
if (!basicValidation.isValid) {
910+
return basicValidation
911+
}
912+
913+
const parsedUrl = new URL(url!)
914+
const hostname = parsedUrl.hostname
915+
916+
try {
917+
const { address } = await dns.lookup(hostname)
918+
919+
if (isPrivateOrReservedIP(address)) {
920+
logger.warn('URL resolves to blocked IP address', {
921+
paramName,
922+
hostname,
923+
resolvedIP: address,
924+
})
925+
return {
926+
isValid: false,
927+
error: `${paramName} resolves to a blocked IP address`,
928+
}
929+
}
930+
931+
return {
932+
isValid: true,
933+
resolvedIP: address,
934+
originalHostname: hostname,
935+
}
936+
} catch (error) {
937+
logger.warn('DNS lookup failed for URL', {
938+
paramName,
939+
hostname,
940+
error: error instanceof Error ? error.message : String(error),
941+
})
942+
return {
943+
isValid: false,
944+
error: `${paramName} hostname could not be resolved`,
945+
}
946+
}
947+
}
948+
949+
/**
950+
* Creates a fetch URL that uses a resolved IP address to prevent DNS rebinding
951+
*
952+
* @param originalUrl - The original URL
953+
* @param resolvedIP - The resolved IP address to use
954+
* @returns The URL with IP substituted for hostname
955+
*/
956+
export function createPinnedUrl(originalUrl: string, resolvedIP: string): string {
957+
const parsed = new URL(originalUrl)
958+
const port = parsed.port ? `:${parsed.port}` : ''
959+
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
960+
}

0 commit comments

Comments
 (0)