Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion apps/sim/app/api/function/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ describe('Function Execute API Route', () => {

it.concurrent('should block SSRF attacks through secure fetch wrapper', async () => {
expect(validateProxyUrl('http://169.254.169.254/latest/meta-data/').isValid).toBe(false)
expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(false)
expect(validateProxyUrl('http://127.0.0.1:8080/admin').isValid).toBe(true)
expect(validateProxyUrl('http://192.168.1.1/config').isValid).toBe(false)
expect(validateProxyUrl('http://10.0.0.1/internal').isValid).toBe(false)
})
Expand Down
21 changes: 16 additions & 5 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,22 @@ 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
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

if (isPrivateOrReservedIP(address) && !isLocalhost) {
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated
logger.warn('URL resolves to blocked IP address', {
paramName,
hostname,
Expand Down Expand Up @@ -189,8 +202,6 @@ export async function secureFetchWithPinnedIP(

const agent = isHttps ? new https.Agent(agentOptions) : new http.Agent(agentOptions)

// Remove accept-encoding since Node.js http/https doesn't auto-decompress
// Headers are lowercase due to Web Headers API normalization in executeToolRequest
const { 'accept-encoding': _, ...sanitizedHeaders } = options.headers ?? {}

const requestOptions: http.RequestOptions = {
Expand All @@ -200,7 +211,7 @@ export async function secureFetchWithPinnedIP(
method: options.method || 'GET',
headers: sanitizedHeaders,
agent,
timeout: options.timeout || 300000, // Default 5 minutes
timeout: options.timeout || 300000,
}

const protocol = isHttps ? https : http
Expand Down
60 changes: 49 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 @@ -898,17 +916,37 @@ describe('validateExternalUrl', () => {
expect(result.isValid).toBe(false)
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 +1027,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
51 changes: 19 additions & 32 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ export function validatePathSegment(
const pathTraversalPatterns = [
'..',
'./',
'.\\.', // Windows path traversal
'%2e%2e', // URL encoded ..
'%252e%252e', // Double URL encoded ..
'.\\.',
'%2e%2e',
'%252e%252e',
'..%2f',
'..%5c',
'%2e%2e%2f',
Expand Down Expand Up @@ -391,7 +391,6 @@ export function validateHostname(

const lowerHostname = hostname.toLowerCase()

// Block localhost
if (lowerHostname === 'localhost') {
logger.warn('Hostname is localhost', { paramName })
return {
Expand All @@ -400,7 +399,6 @@ export function validateHostname(
}
}

// Use ipaddr.js to check if hostname is an IP and if it's private/reserved
if (ipaddr.isValid(lowerHostname)) {
if (isPrivateOrReservedIP(lowerHostname)) {
logger.warn('Hostname matches blocked IP range', {
Expand All @@ -414,7 +412,6 @@ export function validateHostname(
}
}

// Basic hostname format validation
const hostnamePattern =
/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i

Expand Down Expand Up @@ -460,10 +457,7 @@ export function validateFileExtension(
}
}

// Remove leading dot if present
const ext = extension.startsWith('.') ? extension.slice(1) : extension

// Normalize to lowercase
const normalizedExt = ext.toLowerCase()

if (!allowedExtensions.map((e) => e.toLowerCase()).includes(normalizedExt)) {
Expand Down Expand Up @@ -515,7 +509,6 @@ export function validateMicrosoftGraphId(
}
}

// Check for path traversal patterns (../)
const pathTraversalPatterns = [
'../',
'..\\',
Expand All @@ -525,7 +518,7 @@ export function validateMicrosoftGraphId(
'%2e%2e%5c',
'%2e%2e\\',
'..%5c',
'%252e%252e%252f', // double encoded
'%252e%252e%252f',
]

const lowerValue = value.toLowerCase()
Expand All @@ -542,7 +535,6 @@ export function validateMicrosoftGraphId(
}
}

// Check for control characters and null bytes
if (/[\x00-\x1f\x7f]/.test(value) || value.includes('%00')) {
logger.warn('Control characters in Microsoft Graph ID', { paramName })
return {
Expand All @@ -551,16 +543,13 @@ export function validateMicrosoftGraphId(
}
}

// Check for newlines (which could be used for header injection)
if (value.includes('\n') || value.includes('\r')) {
return {
isValid: false,
error: `${paramName} contains invalid newline characters`,
}
}

// Microsoft Graph IDs can contain many characters, but not suspicious patterns
// We've blocked path traversal, so allow the rest
return { isValid: true, sanitized: value }
}

Expand All @@ -583,7 +572,6 @@ export function validateJiraCloudId(
value: string | null | undefined,
paramName = 'cloudId'
): ValidationResult {
// Jira cloud IDs are alphanumeric with hyphens (UUID-like)
return validatePathSegment(value, {
paramName,
allowHyphens: true,
Expand Down Expand Up @@ -612,7 +600,6 @@ export function validateJiraIssueKey(
value: string | null | undefined,
paramName = 'issueKey'
): ValidationResult {
// Jira issue keys: letters, numbers, hyphens (PROJECT-123 format)
return validatePathSegment(value, {
paramName,
allowHyphens: true,
Expand Down Expand Up @@ -653,7 +640,6 @@ export function validateExternalUrl(
}
}

// Must be a valid URL
let parsedUrl: URL
try {
parsedUrl = new URL(url)
Expand All @@ -664,28 +650,29 @@ 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') {
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)) {
if (!isLocalhost && ipaddr.isValid(cleanHostname)) {
if (isPrivateOrReservedIP(cleanHostname)) {
return {
isValid: false,
error: `${paramName} cannot point to private IP addresses`,
Expand Down