Skip to content

Commit c6a112f

Browse files
committed
feat(network): honor HTTP_PROXY, HTTPS_PROXY and NO_PROXY for guarded outbound requests
1 parent 8577aa4 commit c6a112f

9 files changed

Lines changed: 410 additions & 14 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ Configure at least one. Every configured provider stays active and is tried in o
255255
| `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` |
256256
| `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 |
257257
| `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 |
258+
| `DOMAIN_VERIFICATION_NAMESERVERS` | `1.1.1.1,8.8.8.8` | Comma-separated nameserver IPs that SSO domain verification sends its TXT lookup to. Set it when the network only allows DNS to an internal resolver. Entries that are not IP addresses are ignored |
258259

259260
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.
260261

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Most `/api` requests also pass through Next.js Proxy. The current app configurat
265265
266266
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.
267267
268-
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The rest either build their own HTTP agent or speak a non-HTTP protocol.
268+
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables, with the same rules, for every request it sends. The rest either build their own HTTP agent or speak a non-HTTP protocol.
269269
270270
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`. See [Bun fetch](https://bun.sh/docs/runtime/networking/fetch) and [Node proxy configuration](https://nodejs.org/learn/http/enterprise-network-configuration).
271271
@@ -275,16 +275,27 @@ This depends on the runtime. The published images run Bun. If you build the stan
275275
| Email via Resend, Azure Communication Services, and Gmail sending | Yes |
276276
| The desktop update feed's calls to GitHub | Yes |
277277
| Object storage — Azure Blob and GCS | Yes — their SDK pipelines read the proxy variables |
278-
| Everything through the SSRF guard — the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers | No |
279-
| 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 |
278+
| Everything through the SSRF guard — SSO OIDC discovery, the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP servers, and model endpoints typed into a block | Yes — see [Proxying guarded requests](#proxying-guarded-requests) |
279+
| SSO sign-in — the token, JWKS, and userinfo calls made while a user signs in | Yes — they use the default `fetch` |
280+
| SSO domain verification (TXT lookup) | No — DNS, sent to `1.1.1.1` and `8.8.8.8` unless `DOMAIN_VERIFICATION_NAMESERVERS` names your resolvers |
280281
| Amazon Bedrock, and object storage on S3 | No — the AWS SDK uses its own request handler |
281282
| Email via SMTP | No — Nodemailer opens a raw TCP connection |
282283
| Email via Amazon SES | No — the AWS SDK transport, over HTTPS |
283284
| OTLP export from the server SDK | No |
284285
| The `/api/telemetry` relay that forwards browser events | Yes — it uses the default `fetch` |
285286
| Postgres and Redis | No — raw TCP |
286287
287-
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.
288+
The practical consequence: a mandatory-egress-proxy environment can route LLM traffic, SSO, integration and workflow HTTP calls, Resend mail, and Azure/GCS storage through the proxy, but S3, Bedrock, SMTP, SES, OTLP export, and datastore traffic still need a transparent proxy or NAT-based egress.
289+
290+
### Proxying guarded requests
291+
292+
A request through the SSRF guard keeps its checks when it is proxied. Before each request, and again for each redirect hop, the app resolves the destination's hostname with its own DNS and refuses it if any policy in [Security](/platform/self-hosting/security#the-ssrf-boundary) blocks the address. It then hands the hostname, not the address, to the proxy, so a proxy that allowlists by domain sees the name it expects.
293+
294+
- **DNS must still resolve inside the container.** A destination that the app cannot resolve is refused before the proxy is asked.
295+
- **The proxy resolves the name again.** The app cannot pin the address across a proxy, so the proxy's own egress policy is the last check.
296+
- **`https` destinations are tunneled** with `CONNECT host:443`. **`http` destinations are forwarded** to the proxy as an absolute URL, as curl and Bun do.
297+
- **Proxy credentials** go in the URL, e.g. `http://user:pass@proxy.internal:3128`. They are sent only to the proxy. `https://` and `socks5://` proxy URLs also work.
298+
- **The HTTP block's `proxyUrl`** still takes precedence for that one request.
288299
289300
### Internal destinations and `NO_PROXY`
290301

‎apps/sim/lib/auth/sso/domain-verification.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Resolver } from 'node:dns/promises'
2+
import { isIP } from 'node:net'
23
import { createLogger } from '@sim/logger'
34
import { getErrorMessage } from '@sim/utils/errors'
45
import { generateShortId } from '@sim/utils/id'
56
import type { OrganizationDomain } from '@/lib/api/contracts/organization'
7+
import { env } from '@/lib/core/config/env'
68

79
const logger = createLogger('SSODomainVerification')
810

@@ -54,7 +56,24 @@ const TXT_VALUE_PREFIX = 'sim-domain-verification='
5456

5557
/** Public nameservers used for the challenge lookup, so verification does not
5658
* depend on (or get poisoned by) the host's local resolver/split-horizon DNS. */
57-
const VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8']
59+
const DEFAULT_VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8']
60+
61+
/**
62+
* `DOMAIN_VERIFICATION_NAMESERVERS` replaces the public resolvers for deployments whose
63+
* outbound DNS only reaches an internal resolver. An entry that is not an IP address is
64+
* skipped rather than failing every verification.
65+
*/
66+
function resolveVerificationNameservers(): string[] {
67+
const configured = (env.DOMAIN_VERIFICATION_NAMESERVERS ?? '')
68+
.split(',')
69+
.map((entry) => entry.trim())
70+
.filter(Boolean)
71+
const usable = configured.filter((entry) => isIP(entry) !== 0)
72+
if (usable.length < configured.length) {
73+
logger.error('Ignoring DOMAIN_VERIFICATION_NAMESERVERS entries that are not IP addresses')
74+
}
75+
return usable.length > 0 ? usable : DEFAULT_VERIFICATION_NAMESERVERS
76+
}
5877

5978
/**
6079
* Per-attempt timeout. c-ares multiplies this across servers and retries by
@@ -74,12 +93,12 @@ const DNS_TIMEOUT_MS = 2000
7493
const RECORD_ABSENT_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'NXDOMAIN'])
7594

7695
/**
77-
* Shared resolver pinned to the public nameservers. Its config is fully static
96+
* Shared resolver pinned to the verification nameservers. Its config is fully static
7897
* and `resolveTxt` is safe to call concurrently, so a single module-scope
7998
* instance avoids re-allocating one per verification.
8099
*/
81100
const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 })
82-
verificationResolver.setServers(VERIFICATION_NAMESERVERS)
101+
verificationResolver.setServers(resolveVerificationNameservers())
83102

84103
/** The fully-qualified host an org must create the TXT record on. */
85104
export function buildChallengeHost(domain: string): string {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export const env = createEnv({
134134
ALLOW_PRIVATE_DATABASE_HOSTS: z.boolean().optional(), // Deprecated alias for the egress allowlist, kept so existing self-hosted deployments keep working. Equivalent to allowing every private, reserved, and loopback destination. Prefer EGRESS_ALLOWED_HOSTS / EGRESS_ALLOWED_IP_RANGES, which name specific destinations.
135135
EGRESS_ALLOWED_HOSTS: z.string().optional(), // Comma-separated hostnames outbound requests may reach on a private network, leading wildcard allowed (e.g. "host.docker.internal,*.svc.cluster.local"). Self-hosted only; ignored on the hosted platform. Replaces ALLOW_PRIVATE_DATABASE_HOSTS.
136136
EGRESS_ALLOWED_IP_RANGES: z.string().optional(), // Comma-separated CIDRs or IPs outbound requests may reach on a private network (e.g. "10.0.0.0/8,192.168.65.254/32"). Self-hosted only; never lifts the cloud-metadata block.
137+
DOMAIN_VERIFICATION_NAMESERVERS: z.string().optional(), // Comma-separated nameserver IPs for the domain-verification TXT lookup (e.g. "10.0.0.2"). Defaults to 1.1.1.1 and 8.8.8.8; set it where outbound DNS is restricted to an internal resolver.
137138
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
138139
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
139140
BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com")
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { execFile } from 'node:child_process'
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3+
import { createServer as httpServer } from 'node:http'
4+
import { createServer as httpsServer } from 'node:https'
5+
import { type AddressInfo, connect, type Socket } from 'node:net'
6+
import { tmpdir } from 'node:os'
7+
import { join } from 'node:path'
8+
import { getCACertificates, setDefaultCACertificates } from 'node:tls'
9+
import { fileURLToPath } from 'node:url'
10+
import { promisify } from 'node:util'
11+
import { networkConfigMock } from '@sim/testing/mocks/network-config.mock'
12+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
vi.mock('@/lib/core/network/config.server', () => networkConfigMock)
15+
vi.mock('@sim/security/dns', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('@sim/security/dns')>()
17+
const records: Record<string, string> = {
18+
'origin.invalid': '1.1.1.1',
19+
'internal.invalid': '10.0.0.5',
20+
}
21+
return {
22+
...actual,
23+
resolveHostAddresses: async (host: string) => {
24+
const address = records[host]
25+
if (!address) throw new Error(`No addresses for ${host}`)
26+
return { addresses: [address], preferred: address }
27+
},
28+
}
29+
})
30+
31+
import {
32+
createPinnedFetchWithDispatcher,
33+
createSsrfGuardedFetchWithDispatcher,
34+
secureFetchWithPinnedIP,
35+
secureFetchWithValidation,
36+
} from '@/lib/core/security/input-validation.server'
37+
38+
const certificateDirectory = mkdtempSync(join(tmpdir(), 'environment-proxy-tls-'))
39+
const certificatePath = join(certificateDirectory, 'cert.pem')
40+
const keyPath = join(certificateDirectory, 'key.pem')
41+
const sockets = new Set<Socket>()
42+
const tunnels: Array<{ authority: string | undefined; authorization: string | undefined }> = []
43+
const forwarded: Array<{ url: string | undefined; authorization: string | undefined }> = []
44+
const proxiedPorts = new Set<number>()
45+
let originHeaders: Record<string, unknown> = {}
46+
47+
const secureOrigin = httpsServer((req, res) => {
48+
originHeaders = req.headers
49+
res.setHeader('x-via-proxy', proxiedPorts.has(req.socket.remotePort ?? 0) ? 'yes' : 'no')
50+
if (req.url === '/to-internal') {
51+
res.writeHead(302, { location: 'https://internal.invalid/' })
52+
res.end()
53+
return
54+
}
55+
res.end('tls reached')
56+
})
57+
58+
/** A plain forward proxy: CONNECT tunnels reach the TLS origin, absolute-form requests are answered here. */
59+
const proxy = httpServer((req, res) => {
60+
forwarded.push({ url: req.url, authorization: req.headers['proxy-authorization'] })
61+
res.end('forwarded')
62+
})
63+
proxy.on('connect', (req, socket, head) => {
64+
socket.on('error', () => {})
65+
tunnels.push({ authority: req.url, authorization: req.headers['proxy-authorization'] })
66+
const upstream = connect(securePort, '127.0.0.1', () => {
67+
if (upstream.localPort) proxiedPorts.add(upstream.localPort)
68+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
69+
if (head.length) upstream.write(head)
70+
socket.pipe(upstream).pipe(socket)
71+
})
72+
upstream.on('error', () => socket.destroy())
73+
socket.on('close', () => upstream.destroy())
74+
sockets.add(upstream)
75+
upstream.on('close', () => sockets.delete(upstream))
76+
})
77+
78+
let securePort = 0
79+
let proxyUrl = ''
80+
const trust = getCACertificates('default')
81+
const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']
82+
const options = { profile: 'contentFetch' as const, timeout: 10_000 }
83+
84+
beforeAll(async () => {
85+
await promisify(execFile)('openssl', [
86+
'req',
87+
'-x509',
88+
'-newkey',
89+
'rsa:2048',
90+
'-sha256',
91+
'-nodes',
92+
'-keyout',
93+
keyPath,
94+
'-out',
95+
certificatePath,
96+
'-days',
97+
'2',
98+
'-subj',
99+
'/CN=origin.invalid',
100+
'-addext',
101+
'subjectAltName=DNS:origin.invalid,IP:1.1.1.1',
102+
'-addext',
103+
'extendedKeyUsage=serverAuth',
104+
])
105+
const cert = readFileSync(certificatePath, 'utf8')
106+
secureOrigin.setSecureContext({ cert, key: readFileSync(keyPath, 'utf8') })
107+
setDefaultCACertificates([...trust, cert])
108+
for (const server of [secureOrigin, proxy]) {
109+
server.on('connection', (socket) => {
110+
sockets.add(socket)
111+
socket.on('close', () => sockets.delete(socket))
112+
})
113+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
114+
}
115+
securePort = (secureOrigin.address() as AddressInfo).port
116+
proxyUrl = `http://sim:proxy-secret@127.0.0.1:${(proxy.address() as AddressInfo).port}`
117+
})
118+
119+
beforeEach(() => {
120+
for (const name of PROXY_ENV) vi.stubEnv(name, '')
121+
vi.stubEnv('HTTPS_PROXY', proxyUrl)
122+
vi.stubEnv('HTTP_PROXY', proxyUrl)
123+
tunnels.length = 0
124+
forwarded.length = 0
125+
originHeaders = {}
126+
})
127+
128+
afterEach(() => {
129+
vi.unstubAllEnvs()
130+
})
131+
132+
afterAll(async () => {
133+
for (const socket of sockets) socket.destroy()
134+
await Promise.all(
135+
[secureOrigin, proxy].map(
136+
(server) => new Promise<void>((resolve) => server.close(() => resolve()))
137+
)
138+
)
139+
setDefaultCACertificates(trust)
140+
rmSync(certificateDirectory, { recursive: true, force: true })
141+
})
142+
143+
const PROXY_AUTHORIZATION = `Basic ${Buffer.from('sim:proxy-secret').toString('base64')}`
144+
145+
describe('guarded requests under HTTPS_PROXY', () => {
146+
it('tunnels a validated request by hostname and keeps proxy credentials off the origin', async () => {
147+
const response = await secureFetchWithValidation('https://origin.invalid/discovery', {
148+
...options,
149+
headers: { 'Proxy-Authorization': 'must-not-reach-origin' },
150+
})
151+
expect(await response.text()).toBe('tls reached')
152+
expect(response.headers.get('x-via-proxy')).toBe('yes')
153+
expect(tunnels).toEqual([
154+
{ authority: 'origin.invalid:443', authorization: PROXY_AUTHORIZATION },
155+
])
156+
expect(originHeaders['proxy-authorization']).toBeUndefined()
157+
expect(originHeaders.host).toBe('origin.invalid')
158+
})
159+
160+
it('tunnels pinned and DNS-guarded fetches', async () => {
161+
const pinned = createPinnedFetchWithDispatcher('1.1.1.1', options)
162+
const guarded = createSsrfGuardedFetchWithDispatcher(options)
163+
try {
164+
expect(await (await pinned.fetch('https://origin.invalid/')).text()).toBe('tls reached')
165+
expect(await (await guarded.fetch('https://origin.invalid/')).text()).toBe('tls reached')
166+
expect(tunnels.map((tunnel) => tunnel.authority)).toEqual([
167+
'origin.invalid:443',
168+
'origin.invalid:443',
169+
])
170+
} finally {
171+
await Promise.all([pinned.dispatcher.destroy(), guarded.dispatcher.destroy()])
172+
}
173+
})
174+
175+
it('forwards plain-http destinations in absolute form instead of tunneling', async () => {
176+
const response = await secureFetchWithPinnedIP('http://origin.invalid/plain', '1.1.1.1', {
177+
...options,
178+
profile: 'selfHostedService',
179+
})
180+
expect(await response.text()).toBe('forwarded')
181+
expect(tunnels).toEqual([])
182+
expect(forwarded).toEqual([
183+
{ url: 'http://origin.invalid/plain', authorization: PROXY_AUTHORIZATION },
184+
])
185+
})
186+
187+
it('connects directly, still pinned, when NO_PROXY names the host', async () => {
188+
vi.stubEnv('NO_PROXY', 'localhost,.invalid')
189+
const response = await secureFetchWithPinnedIP(
190+
`https://origin.invalid:${securePort}/`,
191+
'127.0.0.1',
192+
options
193+
)
194+
expect(await response.text()).toBe('tls reached')
195+
expect(response.headers.get('x-via-proxy')).toBe('no')
196+
expect(tunnels).toEqual([])
197+
})
198+
199+
it('refuses a destination that resolves to a blocked address without asking the proxy', async () => {
200+
const guarded = createSsrfGuardedFetchWithDispatcher(options)
201+
try {
202+
await expect(guarded.fetch('https://internal.invalid/')).rejects.toThrow(/private|blocked/i)
203+
await expect(
204+
secureFetchWithPinnedIP('https://internal.invalid/', '1.1.1.1', options)
205+
).rejects.toThrow(/private|blocked/i)
206+
} finally {
207+
await guarded.dispatcher.destroy()
208+
}
209+
expect(tunnels).toEqual([])
210+
})
211+
212+
it('judges each redirect hop again before proxying it', async () => {
213+
const pinned = createPinnedFetchWithDispatcher('1.1.1.1', options)
214+
try {
215+
await expect(pinned.fetch('https://origin.invalid/to-internal')).rejects.toThrow(
216+
/private|blocked/i
217+
)
218+
await expect(
219+
secureFetchWithPinnedIP('https://origin.invalid/to-internal', '1.1.1.1', options)
220+
).rejects.toThrow(/private|blocked/i)
221+
} finally {
222+
await pinned.dispatcher.destroy()
223+
}
224+
expect(tunnels.map((tunnel) => tunnel.authority)).toEqual([
225+
'origin.invalid:443',
226+
'origin.invalid:443',
227+
])
228+
})
229+
230+
it('tunnels through the proxy under the actual Bun runtime', async () => {
231+
await promisify(execFile)(
232+
'bun',
233+
[
234+
'--no-env-file',
235+
'run',
236+
fileURLToPath(new URL('./fixtures/environment-proxy-runtime.fixture.ts', import.meta.url)),
237+
],
238+
{
239+
timeout: 15_000,
240+
env: {
241+
...process.env,
242+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
243+
NEXT_PUBLIC_FORCE_HOSTED: 'false',
244+
NODE_EXTRA_CA_CERTS: certificatePath,
245+
HTTPS_PROXY: proxyUrl,
246+
https_proxy: '',
247+
NO_PROXY: '',
248+
no_proxy: '',
249+
},
250+
}
251+
)
252+
expect(tunnels).toEqual([{ authority: '1.1.1.1:443', authorization: PROXY_AUTHORIZATION }])
253+
})
254+
})

0 commit comments

Comments
 (0)