Skip to content

Commit 94d423e

Browse files
committed
improvement(network): reuse validated HTTP transports
1 parent 848b187 commit 94d423e

436 files changed

Lines changed: 3999 additions & 824 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/platform/self-hosting/environment-variables.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ import { Callout } from 'fumadocs-ui/components/callout'
119119
| `LITELLM_BASE_URL` | LiteLLM proxy base URL |
120120
| `LITELLM_API_KEY` | Optional bearer token for LiteLLM |
121121

122+
On self-hosted deployments, the hosts configured in `OLLAMA_URL`, `VLLM_BASE_URL`, `LITELLM_BASE_URL`, `AZURE_OPENAI_ENDPOINT`, `AZURE_ANTHROPIC_ENDPOINT`, and `OCR_AZURE_ENDPOINT` can be reached on private networks without adding them to `EGRESS_ALLOWED_HOSTS`. Existing allowlists still apply. See [model host permissions](/platform/self-hosting/security#the-ssrf-boundary) for which requests this permits.
123+
122124
## Login Providers
123125

124126
Google, GitHub, and Microsoft sign-in, their callback URLs, and the `DISABLE_*_AUTH` switches are documented in [Authentication](/platform/self-hosting/authentication#social-login).

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

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -302,33 +302,28 @@ A proxy body limit of 250 MB accommodates all three defaults. If you lower the a
302302
303303
## Outbound connectivity
304304
305-
The app makes outbound calls to model providers, integration APIs, your email provider, object storage, and your telemetry backend. Whether `HTTP_PROXY` / `HTTPS_PROXY` apply depends on which of those paths a call takes — there is no single answer, and no global setting that covers all of them.
305+
The shared HTTP transport honors `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` on both Bun and Node. This covers guarded HTTP requests, model-provider clients using Sim's fetch adapter, and AWS clients using Sim's shared request handler. It does not configure a global dispatcher or route non-HTTP protocols.
306306
307-
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher, so every call that goes through the default `fetch` is proxied. The rest either build their own HTTP agent or speak a non-HTTP protocol.
307+
Use an `http://` or `https://` proxy URL. For proxy authentication, both the username and password must be nonempty; partial credentials are refused. Lowercase variables take precedence over uppercase ones. `HTTP_PROXY` also applies to HTTPS when `HTTPS_PROXY` is unset. `NO_PROXY` matches the original destination hostname, with optional ports; it accepts exact names, domain suffixes, and `*`, but not CIDR ranges.
308308
309-
<Callout type="warn">
310-
This depends on the runtime. The published images run Bun. If you build the standalone output and run it under Node instead, Node ignores these variables unless started with `NODE_USE_ENV_PROXY=1` (Node 22.21+ / 24.5+), and the `fetch`-based "Yes" rows stop being proxied — model providers, Resend, Gmail sending, the desktop update feed, and the telemetry relay. The Azure Blob, GCS and Azure Communication Services rows still hold: those SDKs read the proxy variables through their own agents rather than through `fetch`.
311-
</Callout>
309+
The transport resolves and validates destinations locally, then sends the approved numeric IP address in a CONNECT request. The original hostname remains in the HTTP Host header and TLS certificate verification. Both HTTP and HTTPS destinations require a proxy that accepts numeric CONNECT authorities. Proxy tunnels use HTTP/1.1; direct connections, including `NO_PROXY` matches, retain each client's HTTP/2 configuration. A proxy that requires hostname-only CONNECT or resolves names unavailable to the application cannot be used by this transport. Use NAT or transparent egress for those environments. Proxy failures never fall back to a direct connection.
310+
311+
An environment proxy is trusted deployment configuration, so its own address can be private or loopback. Its DNS answers are checked and pinned before connecting, and cloud metadata addresses are always refused. This trust does not change destination permissions: content URLs remain public-only, while configured private services still need the applicable [egress allowance](/platform/self-hosting/security#the-ssrf-boundary).
312312
313313
| Outbound path | Honors `HTTP_PROXY` / `HTTPS_PROXY` |
314314
|---|---|
315-
| Model providers reached over the default `fetch` — Anthropic, OpenAI, Google/Gemini, Vertex, Groq, Cerebras, xAI, Mistral, DeepSeek, OpenRouter, Together, Fireworks, Ollama, LiteLLM, and the other OpenAI-compatible providers | Yes |
316-
| Email via Resend, Azure Communication Services, and Gmail sending | Yes |
317-
| The desktop update feed's calls to GitHub | Yes |
318-
| Object storage — Azure Blob and GCS | Yes — their SDK pipelines read the proxy variables |
319-
| Everything through the SSRF guard — the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers | No |
320-
| Azure OpenAI, Azure Anthropic, vLLM | Only when the endpoint comes from `AZURE_OPENAI_ENDPOINT`, `AZURE_ANTHROPIC_ENDPOINT`, or `VLLM_BASE_URL`. An endpoint typed into the block is validated and pinned to its resolved IP, which bypasses the proxy |
321-
| Amazon Bedrock, and object storage on S3 | No — the AWS SDK uses its own request handler |
322-
| Email via SMTP | No — Nodemailer opens a raw TCP connection |
323-
| Email via Amazon SES | No — the AWS SDK transport, over HTTPS |
315+
| Shared guarded HTTP transport: tools, connectors, outbound webhooks, content fetches, MCP HTTP connections, and model-provider clients using Sim's fetch adapter | Yes, on Bun and Node, subject to the CONNECT requirements above |
316+
| Amazon Bedrock and AWS integration clients using Sim's shared request handler | Yes, on Bun and Node |
317+
| Object storage on Azure Blob and GCS; Azure Communication Services email | Yes, through the SDK's own proxy support |
318+
| Native `fetch` paths and SDKs that use it, including Resend email, the desktop update feed, and the `/api/telemetry` relay | Yes on Bun; Node requires [`NODE_USE_ENV_PROXY=1`](https://nodejs.org/api/cli.html#node_use_env_proxy1) (Node 22.21+ / 24.0+) |
319+
| Application object storage on S3; application email via Amazon SES | No, these clients use their own AWS SDK transport |
320+
| Email via SMTP, Postgres, and Redis | No, these use raw TCP |
324321
| OTLP export from the server SDK | No |
325-
| The `/api/telemetry` relay that forwards browser events | Yes — it uses the default `fetch` |
326-
| Postgres and Redis | No — raw TCP |
327322
328-
The practical consequence: a mandatory-egress-proxy environment can route most LLM traffic, Resend mail, and Azure/GCS storage through the proxy, but guarded integration calls, S3, Bedrock, SMTP, telemetry, and datastore traffic still need a transparent proxy or NAT-based egress.
323+
An assigned organization gateway takes precedence over environment proxy settings. An explicit HTTP-block `proxyUrl` takes precedence over environment settings on direct routes, and is refused when organization policy requires a gateway.
329324
330325
<Callout type="info">
331-
Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over the same default `fetch`, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference.
326+
Set `NO_PROXY` for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (`SOCKET_SERVER_URL`), the Presidio PII service (`PII_URL`), and itself (`INTERNAL_API_BASE_URL`) over HTTP, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference.
332327
333328
Set it in the application environment, not your shell — under `app.env` on Helm, or the service's `environment:` on Compose. On Helm the suffixes alone are not enough: the chart wires `SOCKET_SERVER_URL`, `PII_URL`, and `OLLAMA_URL` to bare Service names, which no domain suffix matches. Add those names too — `helm template` prints the rendered ones, and the prefix is the release name unless it already contains `sim`, in which case it is the release name alone:
334329
@@ -349,9 +344,9 @@ The practical consequence: a mandatory-egress-proxy environment can route most L
349344
```
350345
</Callout>
351346
352-
### The per-request escape hatch
347+
### Per-request proxies
353348
354-
The HTTP block's `proxyUrl` is honored per request by the SSRF guard, which builds a proxy agent for that call instead of pinning the target IP. It applies only to that path — connectors, content fetches, and MCP calls take a different guarded transport with no per-request proxy option.
349+
The HTTP block's `proxyUrl` builds a proxy agent for that request. It applies only to the HTTP block; connectors, content fetches, and MCP calls use deployment proxy settings. This per-request path validates the destination locally but lets the proxy resolve the destination hostname, unlike the numeric CONNECT used by environment proxies.
355350
356351
<Callout type="warn">
357352
`proxyUrl` must be an `http://` URL **and** must resolve to a public address. The guard validates the proxy host under a dedicated `proxy` profile that has no operator allowlist, so `EGRESS_ALLOWED_HOSTS` and `EGRESS_ALLOWED_IP_RANGES` do not reach it. A corporate proxy on an RFC 1918 address is refused even when that range is allowlisted for everything else. See [Security](/platform/self-hosting/security#the-ssrf-boundary).

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ Resource ceilings for the in-process path:
172172

173173
## The SSRF boundary
174174

175-
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:
175+
By default Sim blocks outbound requests to private, reserved, and loopback addresses. The allowances below let a self-hosted deployment reach services its operator has configured. The provenances marked **Yes** reach whatever you allowlist. A configured endpoint, self-hosted service, or request target written as `localhost` or a loopback literal is also reachable without an allowlist. That loopback allowance does not lift the blocked-port list or apply to database, cache, and mail connectors. These allowances do not apply on Sim Cloud. Every outbound request is classified by where its URL came from:
176176

177177
| Provenance | Examples | Reaches allowlisted private destinations |
178178
|---|---|---|
@@ -181,9 +181,11 @@ By default Sim blocks outbound requests to private, reserved, and loopback addre
181181
| Request target | The HTTP block's URL, an A2A agent, an RSS feed, a Function block's `fetch` | Yes |
182182
| Database host | A database, cache, or mail connector's host | Yes |
183183
| Content fetch | An image URL, a file imported by URL, a link from a third-party API response | **No** |
184-
| Proxy | The outbound HTTP proxy itself | **No** |
184+
| Per-request proxy | The HTTP block's `proxyUrl` | **No** |
185185

186-
Content fetches never reach a private destination, allowlist or not — that is the class where SSRF is actually exploited. Nor does the proxy: it is the component deciding where everything else may go, so it is held to public destinations regardless of what the allowlist says.
186+
The hosts in `OLLAMA_URL`, `VLLM_BASE_URL`, and `LITELLM_BASE_URL` are also trusted for self-hosted services. The hosts in `AZURE_OPENAI_ENDPOINT`, `AZURE_ANTHROPIC_ENDPOINT`, and `OCR_AZURE_ENDPOINT` are trusted for configured endpoints. These must be HTTP or HTTPS URLs without embedded credentials or wildcard hostnames. This trust covers the exact host or IP address across ports, without replacing your existing allowlists. It does not grant access through an HTTP block, a Function block's `fetch`, or a URL taken from content. Cloud metadata endpoints remain blocked, and model URL settings grant no private-network access on Sim Cloud.
187+
188+
Content fetches never reach a private destination, allowlist or not — that is the class where SSRF is actually exploited. The HTTP block's per-request proxy must also resolve to a public address regardless of the allowlist. Deployment proxies configured through `HTTP_PROXY` or `HTTPS_PROXY` can use private or loopback addresses; their DNS answers are checked and pinned, and metadata endpoints remain blocked. They do not expand which destinations a request may reach. See [Outbound connectivity](/platform/self-hosting/networking#outbound-connectivity).
187189

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

apps/sim/app/api/cron/renew-subscriptions/route.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ vi.mock('@/lib/core/network/config.server', () => ({
3939
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
4040
loadActiveWorkspaceApplicationContext: mocks.workspace,
4141
}))
42-
vi.mock('@/lib/core/security/input-validation.server', () => ({ outboundFetch: mocks.fetch }))
42+
vi.mock('@/lib/core/security/input-validation.server', () => ({
43+
secureFetchWithValidation: mocks.fetch,
44+
}))
4345
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
4446
getCredentialOwner: mocks.credentialOwner,
4547
getNotificationUrl: () => 'https://example.com/api/webhooks/trigger/teams',

apps/sim/app/api/cron/renew-subscriptions/route.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { db } from '@sim/db'
22
import { webhook as webhookTable, workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateShortId } from '@sim/utils/id'
5+
import { isRecordLike } from '@sim/utils/object'
56
import { and, eq, or } from 'drizzle-orm'
67
import { type NextRequest, NextResponse } from 'next/server'
78
import { verifyCronAuth } from '@/lib/auth/internal'
89
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
910
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
10-
import { outboundFetch } from '@/lib/core/security/input-validation.server'
11+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
1112
import { runDetached } from '@/lib/core/utils/background'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1314
import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service'
@@ -44,7 +45,9 @@ async function recreateSubscription(
4445
const notificationUrl = getNotificationUrl(webhook)
4546
const expirationDateTime = new Date(Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000).toISOString()
4647

47-
const res = await outboundFetch('https://graph.microsoft.com/v1.0/subscriptions', {
48+
const res = await secureFetchWithValidation('https://graph.microsoft.com/v1.0/subscriptions', {
49+
profile: 'configuredEndpoint',
50+
redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false },
4851
method: 'POST',
4952
headers: {
5053
Authorization: `Bearer ${accessToken}`,
@@ -65,13 +68,20 @@ async function recreateSubscription(
6568
const error = await res.json()
6669
logger.error(`Failed to recreate Teams subscription for webhook ${webhook.id}`, {
6770
status: res.status,
68-
error: error.error,
71+
error: isRecordLike(error) ? error.error : undefined,
6972
})
7073
return null
7174
}
7275

7376
const payload = await res.json()
74-
return { id: payload.id as string, expirationDateTime: payload.expirationDateTime as string }
77+
if (
78+
!isRecordLike(payload) ||
79+
typeof payload.id !== 'string' ||
80+
typeof payload.expirationDateTime !== 'string'
81+
) {
82+
throw new Error('Invalid Teams subscription response')
83+
}
84+
return { id: payload.id, expirationDateTime: payload.expirationDateTime }
7585
}
7686

7787
/**
@@ -168,9 +178,11 @@ async function renewExpiringSubscriptions(): Promise<{
168178
Date.now() + MAX_LIFETIME_MINUTES * 60 * 1000
169179
).toISOString()
170180

171-
const res = await outboundFetch(
181+
const res = await secureFetchWithValidation(
172182
`https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`,
173183
{
184+
profile: 'configuredEndpoint',
185+
redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false },
174186
method: 'PATCH',
175187
headers: {
176188
Authorization: `Bearer ${accessToken}`,
@@ -187,7 +199,7 @@ async function renewExpiringSubscriptions(): Promise<{
187199
const error = await res.json()
188200
logger.error(
189201
`Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`,
190-
{ status: res.status, error: error.error }
202+
{ status: res.status, error: isRecordLike(error) ? error.error : undefined }
191203
)
192204

193205
if (res.status === 404 || res.status === 410) {
@@ -207,7 +219,10 @@ async function renewExpiringSubscriptions(): Promise<{
207219
}
208220
} else {
209221
const payload = await res.json()
210-
newExpiration = payload.expirationDateTime as string
222+
if (!isRecordLike(payload) || typeof payload.expirationDateTime !== 'string') {
223+
throw new Error('Invalid Teams subscription response')
224+
}
225+
newExpiration = payload.expirationDateTime
211226
}
212227

213228
const updatedConfig = {

apps/sim/app/api/providers/openrouter/embeddings/models/route.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { createMockRequest } from '@sim/testing'
4+
import { createMockRequest, inputValidationMock } from '@sim/testing'
55
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi.hoisted(() => ({
@@ -10,6 +10,11 @@ const { mockFetch, mockFilterBlacklistedModels, mockIsProviderBlacklisted } = vi
1010
mockIsProviderBlacklisted: vi.fn(),
1111
}))
1212

13+
vi.mock('@/lib/core/security/input-validation.server', () => ({
14+
...inputValidationMock,
15+
secureFetchWithValidation: mockFetch,
16+
}))
17+
1318
vi.mock('@/providers/utils', () => ({
1419
filterBlacklistedModels: mockFilterBlacklistedModels,
1520
isProviderBlacklisted: mockIsProviderBlacklisted,
@@ -18,17 +23,19 @@ vi.mock('@/providers/utils', () => ({
1823
import { GET } from '@/app/api/providers/openrouter/embeddings/models/route'
1924

2025
const request = () => createMockRequest('GET')
26+
let clock = 0
2127

2228
describe('GET /api/providers/openrouter/embeddings/models', () => {
2329
beforeEach(() => {
2430
vi.clearAllMocks()
25-
vi.stubGlobal('fetch', mockFetch)
31+
clock += 300_001
32+
vi.spyOn(performance, 'now').mockImplementation(() => clock)
2633
mockIsProviderBlacklisted.mockReturnValue(false)
2734
mockFilterBlacklistedModels.mockImplementation((models: string[]) => models)
2835
})
2936

3037
afterAll(() => {
31-
vi.unstubAllGlobals()
38+
vi.restoreAllMocks()
3239
})
3340

3441
it('returns every unique embedding model with the OpenRouter prefix', async () => {
@@ -50,7 +57,7 @@ describe('GET /api/providers/openrouter/embeddings/models', () => {
5057
})
5158
expect(mockFetch).toHaveBeenCalledWith(
5259
'https://openrouter.ai/api/v1/embeddings/models',
53-
expect.objectContaining({ next: { revalidate: 300 } })
60+
expect.objectContaining({ profile: 'configuredEndpoint', maxResponseBytes: 4 * 1024 * 1024 })
5461
)
5562
})
5663

apps/sim/connectors/asana/asana.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { inputValidationMock } from '@sim/testing'
5+
6+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
7+
48
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
59
import {
610
asanaConnector,

apps/sim/connectors/ashby/ashby.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { inputValidationMock } from '@sim/testing'
5+
6+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
7+
48
import { beforeEach, describe, expect, it, vi } from 'vitest'
59
import { ashbyConnector } from '@/connectors/ashby/ashby'
610
import { ashbyConnectorMeta } from '@/connectors/ashby/meta'

0 commit comments

Comments
 (0)