Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e26760d
fix(egress): let self-hosted deployments reach named private destinat…
waleedlatif1 Aug 28, 2026
6a4ec2d
fix(egress): close regressions found reviewing the profile migration
waleedlatif1 Aug 28, 2026
92ab5a6
fix(egress): merge duplicate afterEach in the alias suite
waleedlatif1 Aug 28, 2026
ee83f71
fix(egress): drop credentials on cross-origin redirects by default
waleedlatif1 Aug 28, 2026
938337c
fix(egress): close review findings on metadata folding, loopback and …
waleedlatif1 Aug 28, 2026
5100740
fix(egress): refuse to replay a request body across an origin boundary
waleedlatif1 Aug 28, 2026
1bfa6d8
refactor(egress): retire the last three parallel SSRF implementations
waleedlatif1 Aug 28, 2026
58cea60
docs(egress): document the allowlist in helm, troubleshooting and MCP
waleedlatif1 Aug 29, 2026
e9b7f0f
chore(helm): bump chart to 1.7.0 for the egress allowlist values
waleedlatif1 Aug 29, 2026
b78574e
refactor(egress): drop a redundant policy dial and prove resolvedIP a…
waleedlatif1 Aug 29, 2026
f07c64e
fix(helm): make lint:helm run
waleedlatif1 Aug 29, 2026
1584c3a
fix(egress): address the second review round
waleedlatif1 Aug 29, 2026
fab48b2
fix(egress): address the third review round
waleedlatif1 Aug 29, 2026
0a50b90
test(knowledge): state the egress profile on the rebased retry test
waleedlatif1 Aug 29, 2026
bb00376
fix(egress): judge IP literals synchronously and match every IPv4 spe…
waleedlatif1 Aug 29, 2026
085e3ae
fix(egress): fold NAT64 addresses, and parse the boundary check properly
waleedlatif1 Aug 29, 2026
2488ca5
fix(egress): close an http2 gap and a regression from the last round
waleedlatif1 Aug 29, 2026
e3f91eb
fix(egress): elide type-only re-exports, and qualify the loopback car…
waleedlatif1 Aug 29, 2026
8036a9d
fix(egress): close two IPv6 fail-opens, and separate a carve-out from…
waleedlatif1 Aug 29, 2026
d3003c7
fix(egress): judge an IPv6 address by the IPv4 it carries, whatever t…
waleedlatif1 Aug 29, 2026
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
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,6 @@ start-collector.sh
# IntelliJ
.idea

## Helm Chart Tests
helm/sim/test

## Claude Code
.claude/launch.json
.claude/worktrees/
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/agents/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ Tool validation badges appear on servers with issues — for example, if a tool

Self-hosted deployments can restrict which MCP server domains are allowed by setting the `ALLOWED_MCP_DOMAINS` environment variable (comma-separated list). When set, only servers on approved domains can be added. When unset, all domains are allowed.

This governs which domains may be used. It is separate from where those domains are allowed to resolve: an MCP server on a private address is reached by naming it in `EGRESS_ALLOWED_HOSTS` or `EGRESS_ALLOWED_IP_RANGES`, described in [Security](/platform/self-hosting/security#the-ssrf-boundary). Both checks apply.

## Using MCP Tools in Agents

Once MCP servers are configured, their tools become available within your agent blocks:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ Without a remote provider, user code runs in an in-process V8 isolate inside the
| `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT` | `75` | Webhook-triggered executions in parallel |
| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled executions in parallel |
| `RESUME_EXECUTION_CONCURRENCY_LIMIT` | `50` | Resumed executions in parallel |
| `ALLOW_PRIVATE_DATABASE_HOSTS` | unset | Let database/connector tools reach private, reserved, and loopback hosts. Loosens the SSRF boundary |
| `EGRESS_ALLOWED_HOSTS` | unset | Comma-separated hostnames outbound requests may reach on a private network. Leading wildcard allowed, e.g. `host.docker.internal,*.svc.cluster.local` |
| `EGRESS_ALLOWED_IP_RANGES` | unset | Comma-separated CIDRs or IPs outbound requests may reach on a private network, e.g. `10.0.0.0/8` |

Your reverse proxy's body-size limit must be at least as large as the app limits above. See [Networking](/platform/self-hosting/networking).

Expand Down
31 changes: 26 additions & 5 deletions apps/docs/content/docs/platform/self-hosting/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,37 @@ Resource ceilings for the in-process path:

## The SSRF boundary

Sim blocks outbound requests from database and connector tools to private, reserved, and loopback addresses. This stops a workflow from being used to scan your internal network.
Sim blocks outbound requests to private, reserved, and loopback addresses. This stops a workflow from being used to scan your internal network. Every outbound request is classified by where its URL came from:
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated

Self-hosted deployments often legitimately need to reach an internal database by service name. That is opt-in:
| Provenance | Examples | Reaches allowlisted private destinations |
|---|---|---|
| Configured endpoint | GitHub Enterprise, Grafana, an MCP server, a data-drain destination, a connector's host | Yes |
| Self-hosted service | vLLM, Jupyter, 1Password Connect, ClickHouse — software usually run on-prem without TLS, so plain HTTP is expected | Yes |
| Request target | The HTTP block's URL, an A2A agent, an RSS feed, a Function block's `fetch` | Yes |
| Database host | A database, cache, or mail connector's host | Yes |
| Content fetch | An image URL, a file imported by URL, a link from a third-party API response | **No** |
Comment thread
waleedlatif1 marked this conversation as resolved.

Content fetches never reach a private destination, allowlist or not — that is the class where SSRF is actually exploited.

Deployments frequently need to reach an internal service by name or address. Name the destinations:

```bash
EGRESS_ALLOWED_HOSTS=host.docker.internal,vllm.ai.svc.cluster.local
EGRESS_ALLOWED_IP_RANGES=10.4.2.17/32,10.4.9.0/24
```

A wildcard (`*.svc.cluster.local`) and a broad range (`10.0.0.0/8`) are accepted, but they hand every workflow author the whole namespace or network. Name the hosts you actually use.

Naming a destination permits plain HTTP to it and lifts the blocked-port list for it, since those are the same decision about the same host. Cloud metadata endpoints (`169.254.169.254` and equivalents) stay blocked no matter how broad the allowlist is, and both variables are ignored entirely on Sim Cloud.
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated

To reach a service on the Docker host, pair the allowlist with the host alias that Compose already sets up:

```bash
ALLOW_PRIVATE_DATABASE_HOSTS=true
EGRESS_ALLOWED_HOSTS=host.docker.internal
```

<Callout type="warn">
This loosens the SSRF boundary for every workflow author on the instance. Enable it only on a trusted private network, and prefer pairing it with a NetworkPolicy that constrains what the app can actually reach.
An allowlist widens what every workflow author on the instance can reach. Name specific hosts and narrow ranges rather than whole private networks, and pair it with a NetworkPolicy that constrains what the app can actually reach.
</Callout>

## Client IP and forwarded headers
Expand Down Expand Up @@ -192,5 +213,5 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m
{ question: "Can I rotate ENCRYPTION_KEY?", answer: "Not without re-encrypting everything it protects. Changing it makes workspace environment variables, stored provider API keys, MCP OAuth credentials, and deployment secrets permanently unreadable. Treat it as a permanent, backed-up value rather than a rotating secret."},
{ question: "Where does user-authored code run?", answer: "By default in an in-process V8 isolate inside the app container, which isolates at the JS-engine level but shares the container's network and filesystem context. For untrusted authors, or to run Python at all, use E2B or Daytona so each execution runs in a remote sandbox."},
{ question: "Why does the chart's NetworkPolicy allow traffic from any pod?", answer: "networkPolicy.ingressFrom defaults to an empty peer selector as a simple default that works on any cluster. On a shared cluster you should scope it to your ingress controller's namespace."},
{ question: "What does ALLOW_PRIVATE_DATABASE_HOSTS change?", answer: "It lets database and connector tools reach private, reserved, and loopback addresses — needed to connect to an internal database by Kubernetes service name. It also widens the SSRF boundary for every workflow author, so enable it only on a trusted network."},
{ question: "How do I reach an internal service from a workflow?", answer: "Name it in EGRESS_ALLOWED_HOSTS (hostnames, leading wildcard allowed) or EGRESS_ALLOWED_IP_RANGES (CIDRs). That permits plain HTTP to it and lifts the blocked-port list for it. Cloud metadata endpoints stay blocked regardless, content fetches never use the allowlist, and both variables are ignored on Sim Cloud."},
]} />
18 changes: 18 additions & 0 deletions apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ OLLAMA_URL=http://host.docker.internal:11434 # macOS/Windows
OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP)
```

## A Workflow Cannot Reach a Service on Your Network

Outbound requests to private, reserved, and loopback addresses are blocked by default, so a workflow pointed at your Docker host, a LAN service, or a Kubernetes service name fails with a message naming the address it resolved to.

Name the destination:

```bash
EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local
EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8
```

Naming a destination also permits plain HTTP to it and lifts the blocked-port list for it. Cloud metadata endpoints (`169.254.169.254` and equivalents) stay blocked however broad the list is, and both variables are ignored on Sim Cloud.

Two things this does not cover:

- Inside a container `localhost` is the container itself, so it will never reach a service on your host. Use `host.docker.internal` (the Compose files map it) and name it above.
- URLs harvested from content or from a third-party API response — an image URL, a file imported by URL — never reach a private network, allowlist or not.

## LM Studio Requests Route to Ollama

Sim identifies dynamically discovered LM Studio and vLLM models by their `vllm/` prefix. If the endpoint is unavailable and you manually enter the raw LM Studio model identifier, Sim treats that unknown identifier as an Ollama model.
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ BETTER_AUTH_URL=http://localhost:3000
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.

# Private Database Hosts (Optional - for self-hosted deployments only)
# ALLOW_PRIVATE_DATABASE_HOSTS=true # Uncomment to let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names, localhost). Loosens the SSRF boundary; only enable on a trusted private network.
# EGRESS_ALLOWED_HOSTS=host.docker.internal,*.svc.cluster.local # Uncomment to let outbound requests reach these hosts on a private network. Widens the SSRF boundary; only use on a trusted private network.
# EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8 # Same, by CIDR. Cloud metadata endpoints stay blocked regardless.

# NextJS (Required)
NEXT_PUBLIC_APP_URL=http://localhost:3000
Expand Down
19 changes: 16 additions & 3 deletions apps/sim/app/api/auth/sso/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,18 @@ type DiscoveryResult =
const OIDC_DISCOVERY_TIMEOUT_MS = 10000

async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<DiscoveryResult> {
const urlValidation = await validateUrlWithDNS(discoveryUrl, 'OIDC discovery URL')
const urlValidation = await validateUrlWithDNS(
discoveryUrl,
'OIDC discovery URL',
'configuredEndpoint'
)
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' }
}

try {
const response = await secureFetchWithPinnedIP(discoveryUrl, urlValidation.resolvedIP, {
profile: 'configuredEndpoint',
headers: { Accept: 'application/json' },
timeout: OIDC_DISCOVERY_TIMEOUT_MS,
})
Expand Down Expand Up @@ -317,7 +322,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

for (const [name, endpointUrl] of Object.entries(userProvidedEndpoints)) {
if (endpointUrl) {
const endpointValidation = await validateUrlWithDNS(endpointUrl, `OIDC ${name}`)
const endpointValidation = await validateUrlWithDNS(
endpointUrl,
`OIDC ${name}`,
'configuredEndpoint'
)
if (!endpointValidation.isValid) {
logger.warn('Explicitly provided OIDC endpoint failed SSRF validation', {
endpoint: name,
Expand Down Expand Up @@ -369,7 +378,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

for (const [key, value] of Object.entries(discoveredEndpoints)) {
if (typeof value === 'string') {
const endpointValidation = await validateUrlWithDNS(value, `OIDC ${key}`)
const endpointValidation = await validateUrlWithDNS(
value,
`OIDC ${key}`,
'contentFetch'
)
if (!endpointValidation.isValid) {
logger.warn('OIDC discovered endpoint failed SSRF validation', {
endpoint: key,
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ function parsePreview(html: string): LinkPreview {

async function fetchPreview(url: string): Promise<LinkPreview> {
const response = await secureFetchWithValidation(url, {
// The URL is harvested from a rendered link rather than authored as a
// destination, so it gets no reach into a private network.
profile: 'contentFetch',
timeout: FETCH_TIMEOUT_MS,
maxRedirects: MAX_REDIRECTS,
maxResponseBytes: MAX_RESPONSE_BYTES,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/mcp/servers/test-connection/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ vi.mock('@/lib/mcp/client', () => ({
}))

vi.mock('@/lib/mcp/domain-check', () => ({
MCP_EGRESS_PROFILE: 'selfHostedService',
OAUTH_EGRESS_PROFILE: 'contentFetch',
McpDnsResolutionError: class extends Error {},
McpDomainNotAllowedError: class extends Error {},
McpSsrfError: MockMcpSsrfError,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/imap/mailboxes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}

const client = new ImapFlow({
host: hostValidation.resolvedIP!,
host: hostValidation.resolvedIP,
servername: host,
port,
secure,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/zoho_desk/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// the resolved IP, block private/reserved hops, and drop the token if a
// Zoho-side redirect leaves the original origin.
const response = await secureFetchWithValidation(agentsUrl.toString(), {
profile: 'configuredEndpoint',
method: 'GET',
headers,
timeout: 15_000,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/zoho_desk/departments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// IP, block private/reserved hops, and drop the token if a Zoho-side
// redirect leaves the original origin.
const response = await secureFetchWithValidation(departmentsUrl.toString(), {
profile: 'configuredEndpoint',
method: 'GET',
headers,
timeout: 15_000,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/zoho_desk/organizations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// IP, blocks private/reserved targets on every hop, and drops the token if
// a redirect leaves the original origin.
const response = await secureFetchWithValidation(organizationsUrl.toString(), {
profile: 'configuredEndpoint',
method: 'GET',
headers: {
Authorization: `Zoho-oauthtoken ${accessToken}`,
Expand Down
15 changes: 12 additions & 3 deletions apps/sim/connectors/gitlab/gitlab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ function parseNextLink(linkHeader: string | null): string | undefined {
*/
async function fetchListing(url: string, accessToken: string): Promise<SecureFetchResponse> {
const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
Comment thread
waleedlatif1 marked this conversation as resolved.
method: 'GET',
headers: authHeaders(accessToken),
})
Expand All @@ -150,7 +151,11 @@ async function fetchListing(url: string, accessToken: string): Promise<SecureFet
}

logger.warn('GitLab rejected keyset pagination; retrying with offset pagination', { url })
return secureFetchWithRetry(offsetUrl, { method: 'GET', headers: authHeaders(accessToken) })
return secureFetchWithRetry(offsetUrl, {
profile: 'configuredEndpoint',
method: 'GET',
headers: authHeaders(accessToken),
})
}

/**
Expand Down Expand Up @@ -547,7 +552,7 @@ async function fetchProject(
): Promise<SecureFetchResponse> {
return secureFetchWithRetry(
`${apiBase}/projects/${encodedProject}`,
{ method: 'GET', headers: authHeaders(accessToken) },
{ profile: 'configuredEndpoint', method: 'GET', headers: authHeaders(accessToken) },
retryOptions
)
}
Expand Down Expand Up @@ -816,6 +821,7 @@ export const gitlabConnector: ConnectorConfig = {
logger.info('Listing GitLab wiki pages', { host, project: encodedProject })

const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
method: 'GET',
headers: authHeaders(accessToken),
})
Expand Down Expand Up @@ -965,6 +971,7 @@ export const gitlabConnector: ConnectorConfig = {

const url = `${apiBase}/projects/${encodedProject}/wikis/${encodeURIComponent(slug)}?render_html=false`
const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
method: 'GET',
headers: authHeaders(accessToken),
})
Expand All @@ -986,6 +993,7 @@ export const gitlabConnector: ConnectorConfig = {

const url = `${apiBase}/projects/${encodedProject}/issues/${iid}`
const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
method: 'GET',
headers: authHeaders(accessToken),
})
Expand Down Expand Up @@ -1013,6 +1021,7 @@ export const gitlabConnector: ConnectorConfig = {
)
const url = `${apiBase}/projects/${encodedProject}/repository/files/${encodeURIComponent(path)}?ref=${encodeURIComponent(ref)}`
const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
method: 'GET',
headers: authHeaders(accessToken),
})
Expand Down Expand Up @@ -1108,7 +1117,7 @@ export const gitlabConnector: ConnectorConfig = {
if (userRef && activePhases(choice).includes('repo')) {
const refResponse = await secureFetchWithRetry(
`${apiBase}/projects/${encodedProject}/repository/commits/${encodeURIComponent(userRef)}`,
{ method: 'GET', headers: authHeaders(accessToken) },
{ profile: 'configuredEndpoint', method: 'GET', headers: authHeaders(accessToken) },
VALIDATE_RETRY_OPTIONS
)
if (refResponse.status === 404) {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/connectors/mintlify/mintlify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function resolveSite(rawUrl: string | undefined): MintlifySite {
url = `https://${url}`
}

const validation = validateExternalUrl(url, 'siteUrl')
const validation = validateExternalUrl(url, 'siteUrl', 'configuredEndpoint')
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid documentation site URL')
}
Expand Down Expand Up @@ -172,6 +172,7 @@ async function fetchSiteText(
const response = await secureFetchWithRetry(
url,
{
profile: 'configuredEndpoint',
method: 'GET',
headers: siteHeaders(accessToken, accept),
stripAuthOnRedirect: true,
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/connectors/obsidian/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function resolveVaultEndpoint(rawUrl: string | undefined): string {
if (url && !url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
const validation = validateExternalUrl(url, 'vaultUrl')
const validation = validateExternalUrl(url, 'vaultUrl', 'configuredEndpoint')
if (!validation.isValid) {
throw new Error(validation.error || 'Invalid vault URL')
}
Expand Down Expand Up @@ -112,6 +112,7 @@ async function listDirectory(
const response = await secureFetchWithRetry(
endpoint,
{
profile: 'configuredEndpoint',
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Expand Down Expand Up @@ -201,6 +202,7 @@ async function fetchNote(
filePath: string
): Promise<NoteJson | null> {
const response = await secureFetchWithRetry(noteUrl(baseUrl, filePath), {
profile: 'configuredEndpoint',
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Expand Down Expand Up @@ -385,6 +387,7 @@ export const obsidianConnector: ConnectorConfig = {
const response = await secureFetchWithRetry(
`${baseUrl}/`,
{
profile: 'configuredEndpoint',
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
stripAuthOnRedirect: true,
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/connectors/s3/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ async function listObjectsPage(

const response = await secureFetchWithRetry(
url,
{ method: 'GET', headers, stripAuthOnRedirect: true },
{ profile: 'configuredEndpoint', method: 'GET', headers, stripAuthOnRedirect: true },
retryOptions
)

Expand Down Expand Up @@ -616,6 +616,7 @@ export const s3Connector: ConnectorConfig = {
const url = buildUrl(ctx, encodedPath, '')

const response = await secureFetchWithRetry(url, {
profile: 'configuredEndpoint',
method: 'GET',
headers,
stripAuthOnRedirect: true,
Expand Down
Loading
Loading