Skip to content

Commit 8036a9d

Browse files
committed
fix(egress): close two IPv6 fail-opens, and separate a carve-out from an allowlist
An audit sweep over the whole diff turned up four things worth fixing in the policy core and two in the profile layer. Fail-opens, both IPv6 translation prefixes the address folding missed: - `::ffff:0:a9fe:a9fe` (RFC 6145 IPv4-translated) was not folded, so a vouched destination whose resolver answered with it reached cloud metadata — the guarantee that metadata is never liftable did not hold for that spelling. - `64:ff9b:1::/48` (RFC 8215 local-use NAT64) carries its IPv4 destination at an offset the network operator chooses, so it cannot be read off the address at all. `ipaddr.js` calls it plain unicast, which made it a route to metadata or loopback under even the strict hosted policy. An address whose real destination cannot be determined is now refused rather than judged on its wrapper. Policy corrections: - The loopback carve-out no longer lifts the blocked-port list. It is granted without anyone asking for it, and loopback is exactly where Sim's own Postgres and Redis listen, so `http://localhost:5432` was reachable from an HTTP block on any self-hosted deployment. Only an operator naming a destination lifts ports now — a vouch carries the kind that earned it. - A hostname that says it is loopback is refused before DNS when the policy does not permit loopback, so the synchronous validator stops accepting `https://localhost/x` on the hosted platform. - `insecureHttp: 'always'` is capped at `whenVouched` on the hosted platform, where nothing is vouched. Software served without TLS is a self-hosted arrangement; a hosted deployment should not send a credential in the clear to a user-supplied MCP or vLLM host. - Wildcard allowlist entries are validated like every other entry. `*.foo.com/x` and `*..com` were accepted silently and then matched nothing. Transport: - An IP-literal redirect target is judged as the literal even when the caller supplied a resolved address, because `net.connect` dials a numeric host directly and the literal is what the socket reaches. - The connect-time lookup classifies against the request's own policy instead of a hand-rolled private-address filter, so an allowlisted private destination the redirect check permitted is no longer stranded at connect. - 300, 305 and 306 are no longer followed. 305 redirects a request into a server-named proxy, which is the one hop a guard must never take. Provenance corrections: a Vision image resolved from an internal file URL and Buffer's media probe are presigned URLs against Sim's own storage, so they take `configuredEndpoint` the way STT and Textract already do — on a self-hosted deployment with private object storage they were unreachable. Microsoft Word's upload URL comes out of a Graph response rather than configuration, so it takes `contentFetch`, matching SharePoint and Windchill. Also: `policyCanVouch` is gone, since the synchronous callers both need the narrow predicate; `validateDatabaseHost` classifies each address once instead of twice; the dead `|| !validation.resolvedIP` conjuncts the discriminated union made unreachable are removed across ~32 call sites; the boundary check scans `background/` and `blocks/`; and the docs correct the MCP row, the in-cluster naming claim, the refusal-message description, and add the upgrade notes.
1 parent e3f91eb commit 8036a9d

57 files changed

Lines changed: 663 additions & 313 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/agents/mcp.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Self-hosted deployments can restrict which MCP server domains are allowed by set
8787

8888
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.
8989

90+
The allowlist covers the server URL itself. If the server requires OAuth, the endpoints its authorization-server metadata names are treated as content rather than as configuration, so they have to be publicly routable.
91+
9092
## Using MCP Tools in Agents
9193

9294
Once MCP servers are configured, their tools become available within your agent blocks:

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,11 @@ Without a remote provider, user code runs in an in-process V8 isolate inside the
147147
| `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT` | `75` | Webhook-triggered executions in parallel |
148148
| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled executions in parallel |
149149
| `RESUME_EXECUTION_CONCURRENCY_LIMIT` | `50` | Resumed executions in parallel |
150-
| `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` |
151-
| `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` |
150+
| `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`. Not honored for URLs harvested from content or a third-party response, nor for an HTTP block's `proxyUrl` |
151+
| `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`. Same exclusions |
152+
| `ALLOW_PRIVATE_DATABASE_HOSTS` | unset | **Deprecated.** Vouches for the entire private address space, for database, cache, and mail connector hosts only. Replace it with the two settings above naming specific destinations |
153+
154+
A malformed entry in either allowlist stops the app at startup with a message naming the setting. See [the SSRF boundary](/platform/self-hosting/security#the-ssrf-boundary) for the accepted syntax and for what the allowlist does and does not reach.
152155

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

apps/docs/content/docs/platform/self-hosting/security.mdx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,12 @@ Resource ceilings for the in-process path:
134134

135135
## The SSRF boundary
136136

137-
By default Sim blocks outbound requests to private, reserved, and loopback addresses. This stops a workflow from being used to scan your internal network. Two things soften it on a self-hosted deployment: the provenances marked **Yes** below reach whatever you allowlist, and a configured endpoint, self-hosted service, or request target written as `localhost` or a loopback literal is reachable without any allowlist at all — a local Ollama or Jupyter is the ordinary case. That second carve-out stops there: a database, cache, or mail connector on `localhost` still has to be allowlisted, because loopback is where Sim's own database and Redis listen. Neither softening applies on Sim Cloud. Every outbound request is classified by where its URL came from:
137+
By default Sim blocks outbound requests to private, reserved, and loopback addresses. This stops a workflow from being used to scan your internal network. Two things soften it on a self-hosted deployment: the provenances marked **Yes** below reach whatever you allowlist, and a configured endpoint, self-hosted service, or request target written as `localhost` or a loopback literal is reachable without any allowlist at all — a local Ollama or Jupyter is the ordinary case. That second carve-out stops short in two places: it does not lift the blocked-port list, and it does not extend to a database, cache, or mail connector on `localhost` loopback is where Sim's own database and Redis listen, so reaching them has to be asked for. Neither softening applies on Sim Cloud. Every outbound request is classified by where its URL came from:
138138

139139
| Provenance | Examples | Reaches allowlisted private destinations |
140140
|---|---|---|
141-
| Configured endpoint | GitHub Enterprise, Grafana, an MCP server, a data-drain destination, a connector's host | Yes |
142-
| Self-hosted service | vLLM, Jupyter, 1Password Connect, ClickHouse — software usually run on-prem without TLS, so plain HTTP is expected | Yes |
141+
| Configured endpoint | GitHub Enterprise, Grafana, a data-drain destination, a connector's host | Yes |
142+
| Self-hosted service | vLLM, Jupyter, 1Password Connect, ClickHouse, an MCP server — software usually run on-prem without TLS, so plain HTTP is expected | Yes |
143143
| Request target | The HTTP block's URL, an A2A agent, an RSS feed, a Function block's `fetch` | Yes |
144144
| Database host | A database, cache, or mail connector's host | Yes |
145145
| Content fetch | An image URL, a file imported by URL, a link from a third-party API response | **No** |
@@ -156,7 +156,9 @@ EGRESS_ALLOWED_IP_RANGES=10.4.2.17/32,10.4.9.0/24
156156

157157
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.
158158

159-
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.
159+
Both lists are validated when Sim starts, and a malformed entry stops it with a message naming the setting. `EGRESS_ALLOWED_HOSTS` takes hostnames only — a URL or a CIDR is rejected — and a wildcard has to be a leading `*.` covering at least two labels, so `*.local` is refused and `*.svc.cluster.local` matches `vllm.ai.svc.cluster.local` but not the bare `svc.cluster.local`. `EGRESS_ALLOWED_IP_RANGES` takes CIDRs and bare addresses; `0.0.0.0/0` is refused as a catch-all.
160+
161+
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. A database, cache, or mail connector's host carries no scheme or port of its own, so naming one of those only lifts the private-address block. 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.
160162

161163
The allowlist reaches the four provenances marked **Yes** above. It does not reach a content fetch, and it does not reach a proxy: an HTTP block's `proxyUrl` must be a public address, because the proxy is what decides where every other request may go. Adding an internal proxy to the allowlist will not make it work.
162164

@@ -167,9 +169,19 @@ EGRESS_ALLOWED_HOSTS=host.docker.internal
167169
```
168170

169171
<Callout type="warn">
170-
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.
172+
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. The chart's own NetworkPolicy permits broad egress on port 443 only, so an allowlisted in-cluster target on another port also has to be added to `networkPolicy.egress`.
171173
</Callout>
172174

175+
### Upgrading from an earlier release
176+
177+
The allowlist replaces four separate escape hatches, so a few deployments that worked before now need a destination named:
178+
179+
- **`ALLOW_PRIVATE_DATABASE_HOSTS`** still works, but it is deprecated and logs a warning at startup. It vouches for the whole private address space for database, cache, and mail connector hosts. Replace it with `EGRESS_ALLOWED_HOSTS` or `EGRESS_ALLOWED_IP_RANGES` naming the hosts you actually use.
180+
- **1Password Connect** on a private, non-loopback address, and an **MCP server** on a private address or reached through a DNS name that points at loopback, are no longer reachable implicitly. Name them.
181+
- **`ALLOWED_MCP_DOMAINS`** governs which domains may be used; it no longer disables the address check, so an MCP server on a private address needs the allowlist too.
182+
- **Content fetches** — an image URL, a file imported by URL, an OIDC endpoint discovered from a provider's metadata, an MCP OAuth endpoint the server's metadata names — never use the allowlist. Those destinations have to be publicly routable.
183+
- **Redirects** are re-judged at every hop, so a redirect that downgrades to plain HTTP or lands on a blocked port is now refused. Credentials are dropped when a redirect crosses origins, and a cross-origin redirect that would carry a request body to the new origin is refused outright rather than replayed — a POST that lands on a cross-origin redirect now fails with a message saying so.
184+
173185
## Client IP and forwarded headers
174186

175187
Behind a load balancer, `X-Forwarded-For` is client-controllable. Set `AUTH_TRUSTED_PROXIES` to your proxies' actual addresses so Better Auth resolves the real client IP, and `TRUSTED_ORIGINS` if users reach Sim from more than one origin. Both are covered in [Authentication](/platform/self-hosting/authentication#behind-a-load-balancer).
@@ -216,5 +228,5 @@ The service bundles ~2.2 GB of spaCy models, so first start takes around three m
216228
{ 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."},
217229
{ 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."},
218230
{ 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."},
219-
{ 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."},
231+
{ 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 HTTP destinations; a database, cache, or mail host carries no scheme or port of its own, so naming it only lifts the private-address block. Cloud metadata endpoints stay blocked regardless, content fetches never use the allowlist, and both variables are ignored on Sim Cloud."},
220232
]} />

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP)
2727

2828
## A Workflow Cannot Reach a Service on Your Network
2929

30-
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.
30+
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 blocker — the private or loopback address it resolved to, a blocked port, or `must use https:// to a public destination` when the URL is plain HTTP — and pointing at the allowlist variables.
3131

3232
Name the destination:
3333

@@ -41,7 +41,7 @@ Naming a destination also permits plain HTTP to it and lifts the blocked-port li
4141
Two things this does not cover:
4242

4343
- 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.
44-
- 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.
44+
- URLs harvested from content or from a third-party API response — an image URL, a file imported by URL, an OIDC or MCP OAuth endpoint discovered from a provider's metadata — never reach a private network, allowlist or not. Nor does an HTTP block's `proxyUrl`.
4545

4646
## LM Studio Requests Route to Ollama
4747

apps/sim/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ BETTER_AUTH_URL=http://localhost:3000
1212
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
1313
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
1414

15-
# Private Database Hosts (Optional - for self-hosted deployments only)
15+
# Private-network egress allowlist (Optional - self-hosted only; ignored on Sim Cloud)
1616
# 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.
17-
# EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8 # Same, by CIDR. Cloud metadata endpoints stay blocked regardless.
17+
# EGRESS_ALLOWED_IP_RANGES=10.0.0.0/8 # Same, by CIDR. Cloud metadata endpoints stay blocked regardless, and neither setting is honored for URLs harvested from content or for a proxy.
1818

1919
# NextJS (Required)
2020
NEXT_PUBLIC_APP_URL=http://localhost:3000

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async function fetchOIDCDiscoveryDocument(discoveryUrl: string): Promise<Discove
6161
'OIDC discovery URL',
6262
'configuredEndpoint'
6363
)
64-
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
64+
if (!urlValidation.isValid) {
6565
return { ok: false, error: urlValidation.error ?? 'SSRF validation failed' }
6666
}
6767

apps/sim/lib/a2a/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export async function createA2AClient(
198198
options: { signal?: AbortSignal } = {}
199199
): Promise<Client> {
200200
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl', 'requestTarget')
201-
if (!validation.isValid || !validation.resolvedIP) {
201+
if (!validation.isValid) {
202202
throw new Error(validation.error || 'Agent URL validation failed')
203203
}
204204
const { resolvedIP } = validation

apps/sim/lib/core/security/egress-end-to-end.server.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,12 @@ afterEach(resetEnvFlagsMock)
5353

5454
// Skipped on a host with no private interface (some CI sandboxes); the policy
5555
// itself is covered without a socket in packages/security.
56-
describe.skipIf(!host)('issue #7200 — reaching a service on a private network', () => {
56+
describe.skipIf(!host)('reaching a service on a private network', () => {
5757
it('refuses an unlisted destination and names the setting that would permit it', async () => {
58+
// https, so the refusal comes from the address rather than the scheme.
5859
await expect(
59-
secureFetchWithValidation(`http://${host}:${port}/`, { profile: 'requestTarget' })
60-
).rejects.toThrow(/EGRESS_ALLOWED_HOSTS/)
60+
secureFetchWithValidation(`https://${host}:${port}/`, { profile: 'requestTarget' })
61+
).rejects.toThrow(/private or reserved address.*EGRESS_ALLOWED_HOSTS/s)
6162
})
6263

6364
it('reaches it over plain HTTP once the operator names the range', async () => {
@@ -80,8 +81,11 @@ describe.skipIf(!host)('issue #7200 — reaching a service on a private network'
8081
secureFetchWithValidation(`https://${host}:${port}/`, { profile: 'contentFetch' })
8182
).rejects.toThrow(/private or reserved address/)
8283
})
84+
})
8385

84-
it('reaches a loopback service without any allowlist, as a self-hosted deployment does', async () => {
86+
// Needs no private interface, so it runs everywhere the suite above may not.
87+
describe('reaching a loopback service', () => {
88+
it('works without any allowlist, as a self-hosted deployment expects', async () => {
8589
const local = createServer((request, response) => {
8690
request.resume()
8791
response.end('local')
@@ -97,4 +101,10 @@ describe.skipIf(!host)('issue #7200 — reaching a service on a private network'
97101
await new Promise<void>((resolve) => local.close(() => resolve()))
98102
}
99103
})
104+
105+
it('does not extend that to a content-provenance URL', async () => {
106+
await expect(
107+
secureFetchWithValidation('https://localhost:1/', { profile: 'contentFetch' })
108+
).rejects.toThrow(/loopback/)
109+
})
100110
})

0 commit comments

Comments
 (0)