Skip to content
Open
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
16 changes: 14 additions & 2 deletions apps/sim/lib/core/security/input-validation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 50 additions & 11 deletions apps/sim/lib/core/security/input-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
30 changes: 16 additions & 14 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down