diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index a81560ea41..62a5dd44c2 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -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' @@ -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 { @@ -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}`) diff --git a/apps/sim/app/api/proxy/route.ts b/apps/sim/app/api/proxy/route.ts index 96e59273bb..08b9a0758d 100644 --- a/apps/sim/app/api/proxy/route.ts +++ b/apps/sim/app/api/proxy/route.ts @@ -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' @@ -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), @@ -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, }) diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index e8250f34da..76193d91ef 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + createPinnedUrl, sanitizeForLogging, validateAlphanumericId, validateEnum, @@ -7,6 +8,7 @@ import { validateHostname, validateNumericId, validatePathSegment, + validateUrlWithDNS, validateUUID, } from '@/lib/core/security/input-validation' @@ -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') + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e3db484157..0d0bd41a3c 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1,3 +1,4 @@ +import dns from 'dns/promises' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('InputValidation') @@ -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 { + 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}` +}