Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/sim/app/api/files/parse/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from 'path'
import binaryExtensionsList from 'binary-extensions'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { isSupportedFileType, parseFile } from '@/lib/file-parsers'
import { createLogger } from '@/lib/logs/console/logger'
import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads'
Expand Down Expand Up @@ -270,7 +270,7 @@ async function handleExternalUrl(
logger.info('Fetching external URL:', url)
logger.info('WorkspaceId for URL save:', workspaceId)

const urlValidation = validateExternalUrl(url, 'fileUrl')
const urlValidation = await validateUrlWithDNS(url, 'fileUrl')
if (!urlValidation.isValid) {
logger.warn(`Blocked external URL request: ${urlValidation.error}`)
return {
Expand Down Expand Up @@ -346,8 +346,12 @@ async function handleExternalUrl(
}
}

const response = await fetch(url, {
const pinnedUrl = createPinnedUrl(url, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Host: urlValidation.originalHostname!,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`)
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { isDev } from '@/lib/core/config/environment'
import { validateProxyUrl } from '@/lib/core/security/input-validation'
import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
Expand Down Expand Up @@ -173,7 +173,7 @@ export async function GET(request: Request) {
return createErrorResponse("Missing 'url' parameter", 400)
}

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

try {
const response = await fetch(targetUrl, {
const pinnedUrl = createPinnedUrl(targetUrl, urlValidation.resolvedIP!)
const response = await fetch(pinnedUrl, {
method: method,
headers: {
...getProxyHeaders(),
...customHeaders,
Host: urlValidation.originalHostname!,
},
body: body || undefined,
})
Expand Down
82 changes: 82 additions & 0 deletions apps/sim/lib/core/security/input-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest'
import {
createPinnedUrl,
sanitizeForLogging,
validateAlphanumericId,
validateEnum,
validateFileExtension,
validateHostname,
validateNumericId,
validatePathSegment,
validateUrlWithDNS,
validateUUID,
} from '@/lib/core/security/input-validation'

Expand Down Expand Up @@ -588,3 +590,83 @@ describe('sanitizeForLogging', () => {
expect(result).toBe(input)
})
})

describe('validateUrlWithDNS', () => {
describe('basic validation', () => {
it('should reject invalid URLs', async () => {
const result = await validateUrlWithDNS('not-a-url')
expect(result.isValid).toBe(false)
expect(result.error).toContain('valid URL')
})

it('should reject http:// URLs', async () => {
const result = await validateUrlWithDNS('http://example.com')
expect(result.isValid).toBe(false)
expect(result.error).toContain('https://')
})

it('should reject localhost URLs', async () => {
const result = await validateUrlWithDNS('https://localhost/api')
expect(result.isValid).toBe(false)
expect(result.error).toContain('localhost')
})

it('should reject private IP URLs', async () => {
const result = await validateUrlWithDNS('https://192.168.1.1/api')
expect(result.isValid).toBe(false)
expect(result.error).toContain('private IP')
})

it('should reject null', async () => {
const result = await validateUrlWithDNS(null)
expect(result.isValid).toBe(false)
})

it('should reject empty string', async () => {
const result = await validateUrlWithDNS('')
expect(result.isValid).toBe(false)
})
})

describe('DNS resolution', () => {
it('should accept valid public URLs and return resolved IP', async () => {
const result = await validateUrlWithDNS('https://example.com')
expect(result.isValid).toBe(true)
expect(result.resolvedIP).toBeDefined()
expect(result.originalHostname).toBe('example.com')
})

it('should reject URLs that resolve to private IPs', async () => {
const result = await validateUrlWithDNS('https://localhost.localdomain')
expect(result.isValid).toBe(false)
})

it('should reject unresolvable hostnames', async () => {
const result = await validateUrlWithDNS('https://this-domain-does-not-exist-xyz123.invalid')
expect(result.isValid).toBe(false)
expect(result.error).toContain('could not be resolved')
})
})
})

describe('createPinnedUrl', () => {
it('should replace hostname with IP', () => {
const result = createPinnedUrl('https://example.com/api/data', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/api/data')
})

it('should preserve port if specified', () => {
const result = createPinnedUrl('https://example.com:8443/api', '93.184.216.34')
expect(result).toBe('https://93.184.216.34:8443/api')
})

it('should preserve query string', () => {
const result = createPinnedUrl('https://example.com/api?foo=bar&baz=qux', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/api?foo=bar&baz=qux')
})

it('should preserve path', () => {
const result = createPinnedUrl('https://example.com/a/b/c/d', '93.184.216.34')
expect(result).toBe('https://93.184.216.34/a/b/c/d')
})
})
108 changes: 108 additions & 0 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dns from 'dns/promises'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('InputValidation')
Expand Down Expand Up @@ -850,3 +851,110 @@ export function validateProxyUrl(
): ValidationResult {
return validateExternalUrl(url, paramName)
}

/**
* Checks if an IP address is private or reserved (not routable on the public internet)
*/
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))
}

/**
* Result type for async URL validation with resolved IP
*/
export interface AsyncValidationResult extends ValidationResult {
resolvedIP?: string
originalHostname?: string
}

/**
* Validates a URL and resolves its DNS to prevent SSRF via DNS rebinding
*
* This function:
* 1. Performs basic URL validation (protocol, format)
* 2. Resolves the hostname to an IP address
* 3. Validates the resolved IP is not private/reserved
* 4. Returns the resolved IP for use in the actual request
*
* @param url - The URL to validate
* @param paramName - Name of the parameter for error messages
* @returns AsyncValidationResult with resolved IP for DNS pinning
*/
export async function validateUrlWithDNS(
url: string | null | undefined,
paramName = 'url'
): Promise<AsyncValidationResult> {
const basicValidation = validateExternalUrl(url, paramName)
if (!basicValidation.isValid) {
return basicValidation
}

const parsedUrl = new URL(url!)
const hostname = parsedUrl.hostname

try {
const { address } = await dns.lookup(hostname)

if (isPrivateOrReservedIP(address)) {
logger.warn('URL resolves to blocked IP address', {
paramName,
hostname,
resolvedIP: address,
})
return {
isValid: false,
error: `${paramName} resolves to a blocked IP address`,
}
}

return {
isValid: true,
resolvedIP: address,
originalHostname: hostname,
}
} catch (error) {
logger.warn('DNS lookup failed for URL', {
paramName,
hostname,
error: error instanceof Error ? error.message : String(error),
})
return {
isValid: false,
error: `${paramName} hostname could not be resolved`,
}
}
}

/**
* Creates a fetch URL that uses a resolved IP address to prevent DNS rebinding
*
* @param originalUrl - The original URL
* @param resolvedIP - The resolved IP address to use
* @returns The URL with IP substituted for hostname
*/
export function createPinnedUrl(originalUrl: string, resolvedIP: string): string {
const parsed = new URL(originalUrl)
const port = parsed.port ? `:${parsed.port}` : ''
return `${parsed.protocol}//${resolvedIP}${port}${parsed.pathname}${parsed.search}`
}