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
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
| `RESEND_API_KEY` | Email service for notifications |
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
| `ALLOWED_MCP_DOMAINS` | Restrict outbound MCP servers to listed domains (comma-separated). Empty = all allowed |
| `ALLOWED_PRIVATE_HOSTS` | Allowlist of hostnames, IPs, or CIDRs exempt from SSRF private-IP blocking (e.g., `gitlab.internal,10.0.0.0/8`). Use to call internal services from HTTP, webhook, database, or MCP blocks |
| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups |

## Example .env
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/content/docs/en/self-hosting/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ Use the correct PostgreSQL image:
image: pgvector/pgvector:pg17 # NOT postgres:17
```

## "URL resolves to a blocked IP address"

Sim refuses outbound calls to private/reserved IP ranges (RFC-1918, link-local, cloud metadata) as SSRF protection. If you need agents, webhooks, or database blocks to reach an internal service (e.g., on-prem GitLab, internal SIEM, in-cluster Postgres), allowlist it:

```bash
# Hostnames, literal IPs, or CIDRs (comma-separated)
ALLOWED_PRIVATE_HOSTS=gitlab.internal,10.112.12.56,10.0.0.0/8
```

- Hostnames are matched case-insensitively against the original URL hostname.
- IPs and CIDRs are matched against the resolved IP after DNS lookup, so `gitlab.internal` resolving to `10.x.x.x` will be allowed if either the hostname or the IP is listed.
- Restart the app after changing the value.

For MCP servers specifically, [`ALLOWED_MCP_DOMAINS`](/mcp#domain-allowlisting) is the preferred control — setting it disables the default private-IP block for MCP entirely, replacing it with the curated domain allowlist.

## Certificate Errors (CERT_HAS_EXPIRED)

If you see SSL certificate errors when calling external APIs:
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export const env = createEnv({
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
ALLOWED_PRIVATE_HOSTS: z.string().optional(), // Comma-separated hostnames, IPs, or CIDRs to exempt from SSRF private-IP blocking (e.g., "gitlab.allot.internal,10.112.12.56,10.0.0.0/8"). Empty = SSRF block enforced for all private/reserved IPs.
ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.

// Azure Configuration - Shared credentials with feature-specific models
Expand Down
134 changes: 134 additions & 0 deletions apps/sim/lib/core/config/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* @vitest-environment node
*/
import { createEnvMock } from '@sim/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/config/env', () => createEnvMock())

import { env } from '@/lib/core/config/env'
import {
__resetAllowedPrivateHostsCacheForTest,
getAllowedPrivateHostsFromEnv,
isAllowlistedPrivateHost,
} from '@/lib/core/config/feature-flags'

function withAllowedPrivateHosts(value: string | undefined) {
;(env as { ALLOWED_PRIVATE_HOSTS?: string }).ALLOWED_PRIVATE_HOSTS = value
__resetAllowedPrivateHostsCacheForTest()
}

describe('getAllowedPrivateHostsFromEnv', () => {
afterEach(() => {
withAllowedPrivateHosts(undefined)
})

it('returns null when env var is unset', () => {
expect(getAllowedPrivateHostsFromEnv()).toBeNull()
})

it('returns null when env var is empty after trimming', () => {
withAllowedPrivateHosts(' , , ')
expect(getAllowedPrivateHostsFromEnv()).toBeNull()
})

it('parses bare hostnames into the hostname set (lowercased)', () => {
withAllowedPrivateHosts('Gitlab.Allot.Internal,siem.allot.internal')
const result = getAllowedPrivateHostsFromEnv()
expect(result?.hostnames).toEqual(new Set(['gitlab.allot.internal', 'siem.allot.internal']))
expect(result?.cidrs).toEqual([])
})

it('parses literal IPs as exact /32 or /128 CIDRs', () => {
withAllowedPrivateHosts('10.112.12.56,fd00::1')
const result = getAllowedPrivateHostsFromEnv()
expect(result?.cidrs).toHaveLength(2)
expect(result?.cidrs[0][1]).toBe(32)
expect(result?.cidrs[1][1]).toBe(128)
})

it('parses CIDR ranges', () => {
withAllowedPrivateHosts('10.0.0.0/8,fd00::/8')
const result = getAllowedPrivateHostsFromEnv()
expect(result?.cidrs).toHaveLength(2)
expect(result?.cidrs[0][1]).toBe(8)
expect(result?.cidrs[1][1]).toBe(8)
})

it('mixes hostnames, IPs, and CIDRs in one list', () => {
withAllowedPrivateHosts('gitlab.internal, 10.0.0.0/8 ,10.112.12.56 ')
const result = getAllowedPrivateHostsFromEnv()
expect(result?.hostnames.has('gitlab.internal')).toBe(true)
expect(result?.cidrs).toHaveLength(2)
})

it('falls back to hostname when CIDR parse fails', () => {
withAllowedPrivateHosts('not-a-cidr/bogus')
const result = getAllowedPrivateHostsFromEnv()
expect(result?.hostnames.has('not-a-cidr/bogus')).toBe(true)
})

it('caches the parse result across calls', () => {
withAllowedPrivateHosts('gitlab.internal')
const first = getAllowedPrivateHostsFromEnv()
;(env as { ALLOWED_PRIVATE_HOSTS?: string }).ALLOWED_PRIVATE_HOSTS = 'changed.internal'
expect(getAllowedPrivateHostsFromEnv()).toBe(first)
})
})

describe('isAllowlistedPrivateHost', () => {
afterEach(() => {
withAllowedPrivateHosts(undefined)
})

it('returns false when env var is unset', () => {
expect(isAllowlistedPrivateHost({ ip: '10.0.0.1' })).toBe(false)
expect(isAllowlistedPrivateHost({ hostname: 'gitlab.internal' })).toBe(false)
})

it('matches hostnames case-insensitively', () => {
withAllowedPrivateHosts('gitlab.allot.internal')
expect(isAllowlistedPrivateHost({ hostname: 'GITLAB.ALLOT.INTERNAL' })).toBe(true)
expect(isAllowlistedPrivateHost({ hostname: 'other.internal' })).toBe(false)
})

it('matches literal IPv4 entries', () => {
withAllowedPrivateHosts('10.112.12.56')
expect(isAllowlistedPrivateHost({ ip: '10.112.12.56' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: '10.112.12.57' })).toBe(false)
})

it('matches IPv4 CIDR ranges', () => {
withAllowedPrivateHosts('10.0.0.0/8')
expect(isAllowlistedPrivateHost({ ip: '10.0.0.1' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: '10.255.255.255' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: '11.0.0.1' })).toBe(false)
expect(isAllowlistedPrivateHost({ ip: '192.168.1.1' })).toBe(false)
})

it('matches IPv6 CIDR ranges', () => {
withAllowedPrivateHosts('fc00::/7')
expect(isAllowlistedPrivateHost({ ip: 'fd00::1' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: 'fd12:3456::1' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: 'fc00::1' })).toBe(true)
expect(isAllowlistedPrivateHost({ ip: '2001:db8::1' })).toBe(false)
})

it('does not cross-match IPv4 and IPv6 ranges', () => {
withAllowedPrivateHosts('10.0.0.0/8')
expect(isAllowlistedPrivateHost({ ip: 'fd00::1' })).toBe(false)
})

it('returns true if either hostname or IP matches', () => {
withAllowedPrivateHosts('gitlab.allot.internal,10.0.0.0/8')
expect(isAllowlistedPrivateHost({ hostname: 'gitlab.allot.internal', ip: '8.8.8.8' })).toBe(
true
)
expect(isAllowlistedPrivateHost({ hostname: 'other.internal', ip: '10.5.5.5' })).toBe(true)
})

it('returns false for unparseable IPs', () => {
withAllowedPrivateHosts('10.0.0.0/8')
expect(isAllowlistedPrivateHost({ ip: 'not-an-ip' })).toBe(false)
})
})
102 changes: 102 additions & 0 deletions apps/sim/lib/core/config/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Environment utility functions for consistent environment detection across the application
*/
import * as ipaddr from 'ipaddr.js'
import { env, getEnv, isFalsy, isTruthy } from './env'

/**
Expand Down Expand Up @@ -267,6 +268,107 @@ export function getAllowedMcpDomainsFromEnv(): string[] | null {
return parsed.length > 0 ? parsed : null
}

/**
* Parsed form of the ALLOWED_PRIVATE_HOSTS env var.
* - `hostnames`: lowercase hostnames matched against the original URL hostname
* - `cidrs`: parsed IP ranges matched against the resolved IP after DNS lookup
*/
export interface AllowedPrivateHosts {
hostnames: Set<string>
cidrs: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]>
}

let cachedAllowedPrivateHosts: AllowedPrivateHosts | null | undefined

/**
* Get the parsed allowlist of private hosts and CIDRs that should bypass SSRF
* private-IP blocking.
*
* Returns null if `ALLOWED_PRIVATE_HOSTS` is unset or has no parseable entries
* (default — full SSRF block enforced). Otherwise returns a structure with
* the lowercase hostnames and pre-parsed CIDR ranges to match against.
*
* Each entry can be:
* - A bare hostname (e.g., `gitlab.allot.internal`) — matched against the
* URL's original hostname, case-insensitive.
* - A literal IPv4/IPv6 address (e.g., `10.112.12.56`) — matched as a /32 or /128.
* - A CIDR range (e.g., `10.0.0.0/8`, `fd00::/8`) — matched against the
* resolved IP after DNS lookup.
*
* The result is cached for the process lifetime; env changes require a restart.
*/
export function getAllowedPrivateHostsFromEnv(): AllowedPrivateHosts | null {
if (cachedAllowedPrivateHosts !== undefined) return cachedAllowedPrivateHosts
if (!env.ALLOWED_PRIVATE_HOSTS) {
cachedAllowedPrivateHosts = null
return null
}
const hostnames = new Set<string>()
const cidrs: AllowedPrivateHosts['cidrs'] = []
for (const raw of env.ALLOWED_PRIVATE_HOSTS.split(',')) {
const entry = raw.trim()
if (!entry) continue
if (entry.includes('/')) {
try {
cidrs.push(ipaddr.parseCIDR(entry))
continue
} catch {
// fall through and treat as hostname
}
}
if (ipaddr.isValid(entry)) {
const addr = ipaddr.process(entry)
cidrs.push([addr, addr.kind() === 'ipv4' ? 32 : 128])
continue
}
hostnames.add(entry.toLowerCase())
}
if (hostnames.size === 0 && cidrs.length === 0) {
cachedAllowedPrivateHosts = null
return null
}
cachedAllowedPrivateHosts = { hostnames, cidrs }
return cachedAllowedPrivateHosts
}

/**
* Returns true if either the original hostname or the resolved IP appears in
* the operator-curated `ALLOWED_PRIVATE_HOSTS` allowlist.
*
* Lets self-hosted deployments call internal services (e.g., GitLab on a 10.x
* address) without disabling SSRF protection entirely.
*
* The caller should still run the standard private-IP check first; this
* function is meant as an override gate after a block decision, not a
* replacement for SSRF validation. When the env var is unset, returns false
* and the default block stands.
*/
export function isAllowlistedPrivateHost(opts: { hostname?: string; ip?: string }): boolean {
const allow = getAllowedPrivateHostsFromEnv()
if (!allow) return false
if (opts.hostname && allow.hostnames.has(opts.hostname.toLowerCase())) return true
if (opts.ip && ipaddr.isValid(opts.ip)) {
try {
const addr = ipaddr.process(opts.ip)
for (const range of allow.cidrs) {
if (addr.kind() !== range[0].kind()) continue
if (addr.match(range)) return true
}
} catch {
// ignore unparseable IPs — caller already handled validation
}
}
return false
}

/**
* Test-only hook to reset the cached `ALLOWED_PRIVATE_HOSTS` parse result so
* each test can swap the underlying env value without process restart.
*/
export function __resetAllowedPrivateHostsCacheForTest(): void {
cachedAllowedPrivateHosts = undefined
}

/**
* Get cost multiplier based on environment
*/
Expand Down
19 changes: 15 additions & 4 deletions apps/sim/lib/core/security/input-validation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { LookupFunction } from 'net'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import * as ipaddr from 'ipaddr.js'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isAllowlistedPrivateHost, isHosted } from '@/lib/core/config/feature-flags'
import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation'

const logger = createLogger('InputValidation')
Expand Down Expand Up @@ -111,7 +111,11 @@ export async function validateUrlWithDNS(
return ip === '127.0.0.1' || ip === '::1'
})()

if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) {
if (
isPrivateOrReservedIP(address) &&
!(isLocalhost && resolvedIsLoopback && !isHosted) &&
!isAllowlistedPrivateHost({ hostname: cleanHostname, ip: address })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowlist bypasses loopback SSRF guard

High Severity

isAllowlistedPrivateHost can clear blocks for loopback and other reserved targets because callers treat it as a blanket override on isPrivateOrReservedIP. On hosted, an allowlisted hostname that resolves to 127.0.0.0/8 can reach local services; MCP already rejects loopback before the allowlist, but validateUrlWithDNS, validateDatabaseHost, and validateHostname do not.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ad06cb. Configure here.

) {
logger.warn('URL resolves to blocked IP address', {
paramName,
hostname,
Expand Down Expand Up @@ -168,14 +172,21 @@ export async function validateDatabaseHost(
return { isValid: false, error: `${paramName} cannot be localhost` }
}

if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
if (
ipaddr.isValid(lowerHost) &&
isPrivateOrReservedIP(lowerHost) &&
!isAllowlistedPrivateHost({ ip: lowerHost })
) {
return { isValid: false, error: `${paramName} cannot be a private IP address` }
}

try {
const { address } = await dns.lookup(host, { verbatim: true })

if (isPrivateOrReservedIP(address)) {
if (
isPrivateOrReservedIP(address) &&
!isAllowlistedPrivateHost({ hostname: lowerHost, ip: address })
) {
logger.warn('Database host resolves to blocked IP address', {
paramName,
hostname: host,
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/lib/core/security/input-validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import * as ipaddr from 'ipaddr.js'
import { isHosted } from '@/lib/core/config/feature-flags'
import { isAllowlistedPrivateHost, isHosted } from '@/lib/core/config/feature-flags'

const logger = createLogger('InputValidation')

Expand Down Expand Up @@ -401,7 +401,7 @@ export function validateHostname(
}

if (ipaddr.isValid(lowerHostname)) {
if (isPrivateOrReservedIP(lowerHostname)) {
if (isPrivateOrReservedIP(lowerHostname) && !isAllowlistedPrivateHost({ ip: lowerHostname })) {
logger.warn('Hostname matches blocked IP range', {
paramName,
hostname: hostname.substring(0, 100),
Expand All @@ -411,6 +411,8 @@ export function validateHostname(
error: `${paramName} cannot be a private IP address or localhost`,
}
}
} else if (isAllowlistedPrivateHost({ hostname: lowerHostname })) {
return { isValid: true, sanitized: lowerHostname }
}
Comment on lines +414 to 416
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Allowlisted hostnames bypass the RFC hostname format check

When isAllowlistedPrivateHost({ hostname: lowerHostname }) is true, validateHostname returns early at line 415, skipping the hostnamePattern regex entirely. The intent of the allowlist is to bypass the SSRF private-IP block, not format validation — but a hostname like my_service.internal (with an underscore, invalid per strict RFC 1123) or any other non-standard entry that happens to be in the allowlist would be returned as isValid: true without a format check. Callers that rely on validateHostname to guarantee a well-formed hostname get a weaker guarantee for allowlisted entries than for ordinary public hostnames.


const hostnamePattern =
Expand Down Expand Up @@ -733,7 +735,7 @@ export function validateExternalUrl(
}

if (!isLocalhost && ipaddr.isValid(cleanHostname)) {
if (isPrivateOrReservedIP(cleanHostname)) {
if (isPrivateOrReservedIP(cleanHostname) && !isAllowlistedPrivateHost({ ip: cleanHostname })) {
return {
isValid: false,
error: `${paramName} cannot point to private IP addresses`,
Expand Down
Loading
Loading