Skip to content

Commit 2ad06cb

Browse files
waleedlatif1claude
andcommitted
feat(security): add ALLOWED_PRIVATE_HOSTS allowlist for SSRF block
Self-hosted operators frequently need agents, webhooks, database blocks, and MCP servers to reach internal services (on-prem GitLab, internal SIEM, in-cluster Postgres) whose hostnames resolve to private IPs. Today the SSRF block is binary — only ALLOWED_MCP_DOMAINS provides an escape, and only for MCP. ALLOWED_PRIVATE_HOSTS accepts a comma-separated list of hostnames, literal IPs, and CIDRs. Entries are matched against both the original hostname and the resolved IP, so "gitlab.internal" or "10.112.12.56" or "10.0.0.0/8" all work. The default (unset) preserves today's full private-IP block. Loopback handling and the hosted-mode tightening are unchanged — the allowlist only narrows the private/reserved range check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 46db406 commit 2ad06cb

11 files changed

Lines changed: 348 additions & 15 deletions

File tree

apps/docs/content/docs/en/self-hosting/environment-variables.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
6969
| `RESEND_API_KEY` | Email service for notifications |
7070
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
7171
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
72+
| `ALLOWED_MCP_DOMAINS` | Restrict outbound MCP servers to listed domains (comma-separated). Empty = all allowed |
73+
| `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 |
7274
| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups |
7375

7476
## Example .env

apps/docs/content/docs/en/self-hosting/troubleshooting.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ Use the correct PostgreSQL image:
5858
image: pgvector/pgvector:pg17 # NOT postgres:17
5959
```
6060
61+
## "URL resolves to a blocked IP address"
62+
63+
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:
64+
65+
```bash
66+
# Hostnames, literal IPs, or CIDRs (comma-separated)
67+
ALLOWED_PRIVATE_HOSTS=gitlab.internal,10.112.12.56,10.0.0.0/8
68+
```
69+
70+
- Hostnames are matched case-insensitively against the original URL hostname.
71+
- 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.
72+
- Restart the app after changing the value.
73+
74+
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.
75+
6176
## Certificate Errors (CERT_HAS_EXPIRED)
6277

6378
If you see SSL certificate errors when calling external APIs:

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const env = createEnv({
132132
BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic")
133133
BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*")
134134
ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed.
135+
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.
135136
ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed.
136137

137138
// Azure Configuration - Shared credentials with feature-specific models
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createEnvMock } from '@sim/testing'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@/lib/core/config/env', () => createEnvMock())
8+
9+
import { env } from '@/lib/core/config/env'
10+
import {
11+
__resetAllowedPrivateHostsCacheForTest,
12+
getAllowedPrivateHostsFromEnv,
13+
isAllowlistedPrivateHost,
14+
} from '@/lib/core/config/feature-flags'
15+
16+
function withAllowedPrivateHosts(value: string | undefined) {
17+
;(env as { ALLOWED_PRIVATE_HOSTS?: string }).ALLOWED_PRIVATE_HOSTS = value
18+
__resetAllowedPrivateHostsCacheForTest()
19+
}
20+
21+
describe('getAllowedPrivateHostsFromEnv', () => {
22+
afterEach(() => {
23+
withAllowedPrivateHosts(undefined)
24+
})
25+
26+
it('returns null when env var is unset', () => {
27+
expect(getAllowedPrivateHostsFromEnv()).toBeNull()
28+
})
29+
30+
it('returns null when env var is empty after trimming', () => {
31+
withAllowedPrivateHosts(' , , ')
32+
expect(getAllowedPrivateHostsFromEnv()).toBeNull()
33+
})
34+
35+
it('parses bare hostnames into the hostname set (lowercased)', () => {
36+
withAllowedPrivateHosts('Gitlab.Allot.Internal,siem.allot.internal')
37+
const result = getAllowedPrivateHostsFromEnv()
38+
expect(result?.hostnames).toEqual(new Set(['gitlab.allot.internal', 'siem.allot.internal']))
39+
expect(result?.cidrs).toEqual([])
40+
})
41+
42+
it('parses literal IPs as exact /32 or /128 CIDRs', () => {
43+
withAllowedPrivateHosts('10.112.12.56,fd00::1')
44+
const result = getAllowedPrivateHostsFromEnv()
45+
expect(result?.cidrs).toHaveLength(2)
46+
expect(result?.cidrs[0][1]).toBe(32)
47+
expect(result?.cidrs[1][1]).toBe(128)
48+
})
49+
50+
it('parses CIDR ranges', () => {
51+
withAllowedPrivateHosts('10.0.0.0/8,fd00::/8')
52+
const result = getAllowedPrivateHostsFromEnv()
53+
expect(result?.cidrs).toHaveLength(2)
54+
expect(result?.cidrs[0][1]).toBe(8)
55+
expect(result?.cidrs[1][1]).toBe(8)
56+
})
57+
58+
it('mixes hostnames, IPs, and CIDRs in one list', () => {
59+
withAllowedPrivateHosts('gitlab.internal, 10.0.0.0/8 ,10.112.12.56 ')
60+
const result = getAllowedPrivateHostsFromEnv()
61+
expect(result?.hostnames.has('gitlab.internal')).toBe(true)
62+
expect(result?.cidrs).toHaveLength(2)
63+
})
64+
65+
it('falls back to hostname when CIDR parse fails', () => {
66+
withAllowedPrivateHosts('not-a-cidr/bogus')
67+
const result = getAllowedPrivateHostsFromEnv()
68+
expect(result?.hostnames.has('not-a-cidr/bogus')).toBe(true)
69+
})
70+
71+
it('caches the parse result across calls', () => {
72+
withAllowedPrivateHosts('gitlab.internal')
73+
const first = getAllowedPrivateHostsFromEnv()
74+
;(env as { ALLOWED_PRIVATE_HOSTS?: string }).ALLOWED_PRIVATE_HOSTS = 'changed.internal'
75+
expect(getAllowedPrivateHostsFromEnv()).toBe(first)
76+
})
77+
})
78+
79+
describe('isAllowlistedPrivateHost', () => {
80+
afterEach(() => {
81+
withAllowedPrivateHosts(undefined)
82+
})
83+
84+
it('returns false when env var is unset', () => {
85+
expect(isAllowlistedPrivateHost({ ip: '10.0.0.1' })).toBe(false)
86+
expect(isAllowlistedPrivateHost({ hostname: 'gitlab.internal' })).toBe(false)
87+
})
88+
89+
it('matches hostnames case-insensitively', () => {
90+
withAllowedPrivateHosts('gitlab.allot.internal')
91+
expect(isAllowlistedPrivateHost({ hostname: 'GITLAB.ALLOT.INTERNAL' })).toBe(true)
92+
expect(isAllowlistedPrivateHost({ hostname: 'other.internal' })).toBe(false)
93+
})
94+
95+
it('matches literal IPv4 entries', () => {
96+
withAllowedPrivateHosts('10.112.12.56')
97+
expect(isAllowlistedPrivateHost({ ip: '10.112.12.56' })).toBe(true)
98+
expect(isAllowlistedPrivateHost({ ip: '10.112.12.57' })).toBe(false)
99+
})
100+
101+
it('matches IPv4 CIDR ranges', () => {
102+
withAllowedPrivateHosts('10.0.0.0/8')
103+
expect(isAllowlistedPrivateHost({ ip: '10.0.0.1' })).toBe(true)
104+
expect(isAllowlistedPrivateHost({ ip: '10.255.255.255' })).toBe(true)
105+
expect(isAllowlistedPrivateHost({ ip: '11.0.0.1' })).toBe(false)
106+
expect(isAllowlistedPrivateHost({ ip: '192.168.1.1' })).toBe(false)
107+
})
108+
109+
it('matches IPv6 CIDR ranges', () => {
110+
withAllowedPrivateHosts('fc00::/7')
111+
expect(isAllowlistedPrivateHost({ ip: 'fd00::1' })).toBe(true)
112+
expect(isAllowlistedPrivateHost({ ip: 'fd12:3456::1' })).toBe(true)
113+
expect(isAllowlistedPrivateHost({ ip: 'fc00::1' })).toBe(true)
114+
expect(isAllowlistedPrivateHost({ ip: '2001:db8::1' })).toBe(false)
115+
})
116+
117+
it('does not cross-match IPv4 and IPv6 ranges', () => {
118+
withAllowedPrivateHosts('10.0.0.0/8')
119+
expect(isAllowlistedPrivateHost({ ip: 'fd00::1' })).toBe(false)
120+
})
121+
122+
it('returns true if either hostname or IP matches', () => {
123+
withAllowedPrivateHosts('gitlab.allot.internal,10.0.0.0/8')
124+
expect(isAllowlistedPrivateHost({ hostname: 'gitlab.allot.internal', ip: '8.8.8.8' })).toBe(
125+
true
126+
)
127+
expect(isAllowlistedPrivateHost({ hostname: 'other.internal', ip: '10.5.5.5' })).toBe(true)
128+
})
129+
130+
it('returns false for unparseable IPs', () => {
131+
withAllowedPrivateHosts('10.0.0.0/8')
132+
expect(isAllowlistedPrivateHost({ ip: 'not-an-ip' })).toBe(false)
133+
})
134+
})

apps/sim/lib/core/config/feature-flags.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Environment utility functions for consistent environment detection across the application
33
*/
4+
import * as ipaddr from 'ipaddr.js'
45
import { env, getEnv, isFalsy, isTruthy } from './env'
56

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

271+
/**
272+
* Parsed form of the ALLOWED_PRIVATE_HOSTS env var.
273+
* - `hostnames`: lowercase hostnames matched against the original URL hostname
274+
* - `cidrs`: parsed IP ranges matched against the resolved IP after DNS lookup
275+
*/
276+
export interface AllowedPrivateHosts {
277+
hostnames: Set<string>
278+
cidrs: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]>
279+
}
280+
281+
let cachedAllowedPrivateHosts: AllowedPrivateHosts | null | undefined
282+
283+
/**
284+
* Get the parsed allowlist of private hosts and CIDRs that should bypass SSRF
285+
* private-IP blocking.
286+
*
287+
* Returns null if `ALLOWED_PRIVATE_HOSTS` is unset or has no parseable entries
288+
* (default — full SSRF block enforced). Otherwise returns a structure with
289+
* the lowercase hostnames and pre-parsed CIDR ranges to match against.
290+
*
291+
* Each entry can be:
292+
* - A bare hostname (e.g., `gitlab.allot.internal`) — matched against the
293+
* URL's original hostname, case-insensitive.
294+
* - A literal IPv4/IPv6 address (e.g., `10.112.12.56`) — matched as a /32 or /128.
295+
* - A CIDR range (e.g., `10.0.0.0/8`, `fd00::/8`) — matched against the
296+
* resolved IP after DNS lookup.
297+
*
298+
* The result is cached for the process lifetime; env changes require a restart.
299+
*/
300+
export function getAllowedPrivateHostsFromEnv(): AllowedPrivateHosts | null {
301+
if (cachedAllowedPrivateHosts !== undefined) return cachedAllowedPrivateHosts
302+
if (!env.ALLOWED_PRIVATE_HOSTS) {
303+
cachedAllowedPrivateHosts = null
304+
return null
305+
}
306+
const hostnames = new Set<string>()
307+
const cidrs: AllowedPrivateHosts['cidrs'] = []
308+
for (const raw of env.ALLOWED_PRIVATE_HOSTS.split(',')) {
309+
const entry = raw.trim()
310+
if (!entry) continue
311+
if (entry.includes('/')) {
312+
try {
313+
cidrs.push(ipaddr.parseCIDR(entry))
314+
continue
315+
} catch {
316+
// fall through and treat as hostname
317+
}
318+
}
319+
if (ipaddr.isValid(entry)) {
320+
const addr = ipaddr.process(entry)
321+
cidrs.push([addr, addr.kind() === 'ipv4' ? 32 : 128])
322+
continue
323+
}
324+
hostnames.add(entry.toLowerCase())
325+
}
326+
if (hostnames.size === 0 && cidrs.length === 0) {
327+
cachedAllowedPrivateHosts = null
328+
return null
329+
}
330+
cachedAllowedPrivateHosts = { hostnames, cidrs }
331+
return cachedAllowedPrivateHosts
332+
}
333+
334+
/**
335+
* Returns true if either the original hostname or the resolved IP appears in
336+
* the operator-curated `ALLOWED_PRIVATE_HOSTS` allowlist.
337+
*
338+
* Lets self-hosted deployments call internal services (e.g., GitLab on a 10.x
339+
* address) without disabling SSRF protection entirely.
340+
*
341+
* The caller should still run the standard private-IP check first; this
342+
* function is meant as an override gate after a block decision, not a
343+
* replacement for SSRF validation. When the env var is unset, returns false
344+
* and the default block stands.
345+
*/
346+
export function isAllowlistedPrivateHost(opts: { hostname?: string; ip?: string }): boolean {
347+
const allow = getAllowedPrivateHostsFromEnv()
348+
if (!allow) return false
349+
if (opts.hostname && allow.hostnames.has(opts.hostname.toLowerCase())) return true
350+
if (opts.ip && ipaddr.isValid(opts.ip)) {
351+
try {
352+
const addr = ipaddr.process(opts.ip)
353+
for (const range of allow.cidrs) {
354+
if (addr.kind() !== range[0].kind()) continue
355+
if (addr.match(range)) return true
356+
}
357+
} catch {
358+
// ignore unparseable IPs — caller already handled validation
359+
}
360+
}
361+
return false
362+
}
363+
364+
/**
365+
* Test-only hook to reset the cached `ALLOWED_PRIVATE_HOSTS` parse result so
366+
* each test can swap the underlying env value without process restart.
367+
*/
368+
export function __resetAllowedPrivateHostsCacheForTest(): void {
369+
cachedAllowedPrivateHosts = undefined
370+
}
371+
270372
/**
271373
* Get cost multiplier based on environment
272374
*/

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { LookupFunction } from 'net'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
77
import * as ipaddr from 'ipaddr.js'
8-
import { isHosted } from '@/lib/core/config/feature-flags'
8+
import { isAllowlistedPrivateHost, isHosted } from '@/lib/core/config/feature-flags'
99
import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation'
1010

1111
const logger = createLogger('InputValidation')
@@ -111,7 +111,11 @@ export async function validateUrlWithDNS(
111111
return ip === '127.0.0.1' || ip === '::1'
112112
})()
113113

114-
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback && !isHosted)) {
114+
if (
115+
isPrivateOrReservedIP(address) &&
116+
!(isLocalhost && resolvedIsLoopback && !isHosted) &&
117+
!isAllowlistedPrivateHost({ hostname: cleanHostname, ip: address })
118+
) {
115119
logger.warn('URL resolves to blocked IP address', {
116120
paramName,
117121
hostname,
@@ -168,14 +172,21 @@ export async function validateDatabaseHost(
168172
return { isValid: false, error: `${paramName} cannot be localhost` }
169173
}
170174

171-
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
175+
if (
176+
ipaddr.isValid(lowerHost) &&
177+
isPrivateOrReservedIP(lowerHost) &&
178+
!isAllowlistedPrivateHost({ ip: lowerHost })
179+
) {
172180
return { isValid: false, error: `${paramName} cannot be a private IP address` }
173181
}
174182

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

178-
if (isPrivateOrReservedIP(address)) {
186+
if (
187+
isPrivateOrReservedIP(address) &&
188+
!isAllowlistedPrivateHost({ hostname: lowerHost, ip: address })
189+
) {
179190
logger.warn('Database host resolves to blocked IP address', {
180191
paramName,
181192
hostname: host,

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import * as ipaddr from 'ipaddr.js'
3-
import { isHosted } from '@/lib/core/config/feature-flags'
3+
import { isAllowlistedPrivateHost, isHosted } from '@/lib/core/config/feature-flags'
44

55
const logger = createLogger('InputValidation')
66

@@ -401,7 +401,7 @@ export function validateHostname(
401401
}
402402

403403
if (ipaddr.isValid(lowerHostname)) {
404-
if (isPrivateOrReservedIP(lowerHostname)) {
404+
if (isPrivateOrReservedIP(lowerHostname) && !isAllowlistedPrivateHost({ ip: lowerHostname })) {
405405
logger.warn('Hostname matches blocked IP range', {
406406
paramName,
407407
hostname: hostname.substring(0, 100),
@@ -411,6 +411,8 @@ export function validateHostname(
411411
error: `${paramName} cannot be a private IP address or localhost`,
412412
}
413413
}
414+
} else if (isAllowlistedPrivateHost({ hostname: lowerHostname })) {
415+
return { isValid: true, sanitized: lowerHostname }
414416
}
415417

416418
const hostnamePattern =
@@ -733,7 +735,7 @@ export function validateExternalUrl(
733735
}
734736

735737
if (!isLocalhost && ipaddr.isValid(cleanHostname)) {
736-
if (isPrivateOrReservedIP(cleanHostname)) {
738+
if (isPrivateOrReservedIP(cleanHostname) && !isAllowlistedPrivateHost({ ip: cleanHostname })) {
737739
return {
738740
isValid: false,
739741
error: `${paramName} cannot point to private IP addresses`,

0 commit comments

Comments
 (0)