Skip to content

Commit 816b4a0

Browse files
aayush598waleedlatif1
authored andcommitted
fix(security): allow localhost HTTP without weakening SSRF protections
1 parent 04286fc commit 816b4a0

File tree

3 files changed

+80
-27
lines changed

3 files changed

+80
-27
lines changed

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,21 @@ export async function validateUrlWithDNS(
6565
const hostname = parsedUrl.hostname
6666

6767
try {
68-
const { address } = await dns.lookup(hostname)
68+
const lookupHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
69+
const { address } = await dns.lookup(lookupHostname, { verbatim: true })
6970

70-
if (isPrivateOrReservedIP(address)) {
71+
const hostnameLower = hostname.toLowerCase()
72+
73+
let isLocalhost = hostnameLower === 'localhost'
74+
75+
if (ipaddr.isValid(address)) {
76+
const processedIP = ipaddr.process(address).toString()
77+
if (processedIP === '127.0.0.1' || processedIP === '::1') {
78+
isLocalhost = true
79+
}
80+
}
81+
82+
if (isPrivateOrReservedIP(address) && !isLocalhost) {
7183
logger.warn('URL resolves to blocked IP address', {
7284
paramName,
7385
hostname,

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

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -569,10 +569,28 @@ describe('validateUrlWithDNS', () => {
569569
expect(result.error).toContain('https://')
570570
})
571571

572-
it('should reject localhost URLs', async () => {
572+
it('should accept https localhost URLs', async () => {
573573
const result = await validateUrlWithDNS('https://localhost/api')
574-
expect(result.isValid).toBe(false)
575-
expect(result.error).toContain('localhost')
574+
expect(result.isValid).toBe(true)
575+
expect(result.resolvedIP).toBeDefined()
576+
})
577+
578+
it('should accept http localhost URLs', async () => {
579+
const result = await validateUrlWithDNS('http://localhost/api')
580+
expect(result.isValid).toBe(true)
581+
expect(result.resolvedIP).toBeDefined()
582+
})
583+
584+
it('should accept IPv4 loopback URLs', async () => {
585+
const result = await validateUrlWithDNS('http://127.0.0.1/api')
586+
expect(result.isValid).toBe(true)
587+
expect(result.resolvedIP).toBeDefined()
588+
})
589+
590+
it('should accept IPv6 loopback URLs', async () => {
591+
const result = await validateUrlWithDNS('http://[::1]/api')
592+
expect(result.isValid).toBe(true)
593+
expect(result.resolvedIP).toBeDefined()
576594
})
577595

578596
it('should reject private IP URLs', async () => {
@@ -899,16 +917,37 @@ describe('validateExternalUrl', () => {
899917
expect(result.error).toContain('valid URL')
900918
})
901919

902-
it.concurrent('should reject localhost', () => {
920+
})
921+
922+
describe('localhost and loopback addresses', () => {
923+
it.concurrent('should accept https localhost', () => {
903924
const result = validateExternalUrl('https://localhost/api')
904-
expect(result.isValid).toBe(false)
905-
expect(result.error).toContain('localhost')
925+
expect(result.isValid).toBe(true)
906926
})
907927

908-
it.concurrent('should reject 127.0.0.1', () => {
928+
it.concurrent('should accept http localhost', () => {
929+
const result = validateExternalUrl('http://localhost/api')
930+
expect(result.isValid).toBe(true)
931+
})
932+
933+
it.concurrent('should accept https 127.0.0.1', () => {
909934
const result = validateExternalUrl('https://127.0.0.1/api')
910-
expect(result.isValid).toBe(false)
911-
expect(result.error).toContain('private IP')
935+
expect(result.isValid).toBe(true)
936+
})
937+
938+
it.concurrent('should accept http 127.0.0.1', () => {
939+
const result = validateExternalUrl('http://127.0.0.1/api')
940+
expect(result.isValid).toBe(true)
941+
})
942+
943+
it.concurrent('should accept https IPv6 loopback', () => {
944+
const result = validateExternalUrl('https://[::1]/api')
945+
expect(result.isValid).toBe(true)
946+
})
947+
948+
it.concurrent('should accept http IPv6 loopback', () => {
949+
const result = validateExternalUrl('http://[::1]/api')
950+
expect(result.isValid).toBe(true)
912951
})
913952

914953
it.concurrent('should reject 0.0.0.0', () => {
@@ -989,9 +1028,9 @@ describe('validateImageUrl', () => {
9891028
expect(result.isValid).toBe(true)
9901029
})
9911030

992-
it.concurrent('should reject localhost URLs', () => {
1031+
it.concurrent('should accept localhost URLs', () => {
9931032
const result = validateImageUrl('https://localhost/image.png')
994-
expect(result.isValid).toBe(false)
1033+
expect(result.isValid).toBe(true)
9951034
})
9961035

9971036
it.concurrent('should use imageUrl as default param name', () => {

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

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -664,28 +664,30 @@ export function validateExternalUrl(
664664
}
665665
}
666666

667-
// Only allow https protocol
668-
if (parsedUrl.protocol !== 'https:') {
669-
return {
670-
isValid: false,
671-
error: `${paramName} must use https:// protocol`,
667+
const protocol = parsedUrl.protocol
668+
const hostname = parsedUrl.hostname.toLowerCase()
669+
670+
const cleanHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
671+
672+
let isLocalhost = cleanHostname === 'localhost'
673+
if (ipaddr.isValid(cleanHostname)) {
674+
const processedIP = ipaddr.process(cleanHostname).toString()
675+
if (processedIP === '127.0.0.1' || processedIP === '::1') {
676+
isLocalhost = true
672677
}
673678
}
674679

675-
// Block private IP ranges and localhost
676-
const hostname = parsedUrl.hostname.toLowerCase()
677-
678-
// Block localhost
679-
if (hostname === 'localhost') {
680+
// Require HTTPS except for localhost development
681+
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
680682
return {
681683
isValid: false,
682-
error: `${paramName} cannot point to localhost`,
684+
error: `${paramName} must use https:// protocol`,
683685
}
684686
}
685687

686-
// Use ipaddr.js to check if hostname is an IP and if it's private/reserved
687-
if (ipaddr.isValid(hostname)) {
688-
if (isPrivateOrReservedIP(hostname)) {
688+
// Block private/reserved IPs while allowing loopback addresses for local development.
689+
if (!isLocalhost && ipaddr.isValid(cleanHostname)) {
690+
if (isPrivateOrReservedIP(cleanHostname)) {
689691
return {
690692
isValid: false,
691693
error: `${paramName} cannot point to private IP addresses`,

0 commit comments

Comments
 (0)