Skip to content

Commit f788124

Browse files
committed
fix(runtime-secrets): pick the boot fetch's proxy with the app's rules
Bun's fetch picks a proxy by scheme only, so with just HTTP_PROXY set the https Secrets Manager request went direct and a proxy-only container still could not boot. Move proxy selection (HTTPS_PROXY falling back to HTTP_PROXY, curl-style NO_PROXY) from apps/sim into @sim/security/environment-proxy, and have runtime-secrets pass the selected proxy to Bun's fetch explicitly, or send through the Node handler when NO_PROXY excludes the endpoint.
1 parent 297f796 commit f788124

10 files changed

Lines changed: 109 additions & 47 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac
301301
302302
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. AWS clients and OTLP export follow the same rule, so also add an internal S3-compatible store such as MinIO, AWS VPC endpoints, and an in-cluster OpenTelemetry collector.
303303
304-
For the SSRF guard, AWS clients, and OTLP export, Sim matches `NO_PROXY` entries the way curl does: `example.com`, `.example.com`, and `*.example.com` each cover the domain and all its subdomains; `host:port` and `[::1]:port` cover one port; IPv6 entries work bare (`::1`) or bracketed; and CIDR ranges such as `10.0.0.0/8` cover IP-literal destinations only, not hostnames that resolve into the range. Bun's default `fetch`, which carries internal service URLs such as the realtime server and PII service and, behind a proxy, the boot-time Secrets Manager fetch, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname.
304+
For the SSRF guard, AWS clients (including the boot-time Secrets Manager fetch), and OTLP export, Sim matches `NO_PROXY` entries the way curl does: `example.com`, `.example.com`, and `*.example.com` each cover the domain and all its subdomains; `host:port` and `[::1]:port` cover one port; IPv6 entries work bare (`::1`) or bracketed; and CIDR ranges such as `10.0.0.0/8` cover IP-literal destinations only, not hostnames that resolve into the range. Bun's default `fetch`, which carries internal service URLs such as the realtime server and PII service, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname.
305305
306306
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:
307307

‎apps/sim/lib/core/network/environment-proxy.server.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ vi.mock('@sim/security/dns', async (importOriginal) => {
3131
}
3232
})
3333

34-
import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server'
34+
import { resolveEnvironmentProxy } from '@sim/security/environment-proxy'
3535
import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server'
3636
import {
3737
createPinnedFetchWithDispatcher,

‎apps/sim/lib/core/network/proxy-agent.server.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import http from 'node:http'
22
import https from 'node:https'
33
import { isIP } from 'node:net'
4+
import {
5+
isEnvironmentProxyConfigured,
6+
resolveEnvironmentProxy,
7+
} from '@sim/security/environment-proxy'
48
import { NodeHttpHandler } from '@smithy/node-http-handler'
59
import { Agent, type AgentConnectOpts } from 'agent-base'
610
import { HttpProxyAgent } from 'http-proxy-agent'
711
import { HttpsProxyAgent } from 'https-proxy-agent'
8-
import {
9-
isEnvironmentProxyConfigured,
10-
resolveEnvironmentProxy,
11-
} from '@/lib/core/network/environment-proxy.server'
1212

1313
/** Matches the AWS SDK's own default agent, so swapping it in changes routing and nothing else. */
1414
const AGENT_OPTIONS: http.AgentOptions = { keepAlive: true, maxSockets: 50 }

‎apps/sim/lib/core/network/transport.server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import { resolveEnvironmentProxy } from '@sim/security/environment-proxy'
12
import { type Agent, type Dispatcher, ProxyAgent, request } from 'undici/index.js'
23
import { resolveCurrentOutboundRoute } from '@/lib/core/network/context.server'
3-
import { resolveEnvironmentProxy } from '@/lib/core/network/environment-proxy.server'
44
import { createGatewayDispatcher } from '@/lib/core/network/gateway.server'
55
import { OutboundRoutingError } from '@/lib/core/network/routing'
66
import type { EgressProfile } from '@/lib/core/security/egress/profiles'

‎bun.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/runtime-secrets/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
"dependencies": {
3232
"@aws-sdk/client-secrets-manager": "3.1150.0",
3333
"@sim/logger": "workspace:*",
34+
"@sim/security": "workspace:*",
3435
"@sim/utils": "workspace:*",
35-
"@smithy/fetch-http-handler": "5.8.0"
36+
"@smithy/fetch-http-handler": "5.8.0",
37+
"@smithy/node-http-handler": "4.12.1"
3638
},
3739
"devDependencies": {
3840
"@sim/tsconfig": "workspace:*",

‎packages/runtime-secrets/src/index.ts‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { GetSecretValueCommandOutput } from '@aws-sdk/client-secrets-manager'
22
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'
33
import { createLogger } from '@sim/logger'
4+
import {
5+
isEnvironmentProxyConfigured,
6+
resolveEnvironmentProxy,
7+
} from '@sim/security/environment-proxy'
48
import { getErrorMessage } from '@sim/utils/errors'
59
import { sleep } from '@sim/utils/helpers'
610
import { backoffWithJitter } from '@sim/utils/retry'
711
import { FetchHttpHandler } from '@smithy/fetch-http-handler'
12+
import { NodeHttpHandler } from '@smithy/node-http-handler'
813

914
const logger = createLogger('RuntimeSecrets')
1015

@@ -71,20 +76,53 @@ export async function fetchRuntimeSecrets(secretId: string): Promise<Record<stri
7176
)
7277
}
7378

74-
const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'] as const
79+
/**
80+
* Sends each request the way the app's `awsRequestHandler` would: through the `HTTPS_PROXY` /
81+
* `HTTP_PROXY` that applies to its destination (an https endpoint falls back to `HTTP_PROXY`),
82+
* or directly when `NO_PROXY` excludes it, using the same rules from `@sim/security`. Proxied
83+
* requests go through Bun's `fetch` with the proxy passed explicitly, since Bun's own lookup
84+
* picks a proxy by scheme only and would skip an https endpoint when just `HTTP_PROXY` is set.
85+
*/
86+
class EnvironmentProxyHandler extends FetchHttpHandler {
87+
private readonly direct = new NodeHttpHandler()
88+
89+
constructor() {
90+
super({
91+
customFetch: (input, init) => {
92+
const url = input instanceof Request ? input.url : String(input)
93+
const proxied: RequestInit & { proxy?: string } = {
94+
...init,
95+
proxy: resolveEnvironmentProxy(new URL(url)) ?? undefined,
96+
}
97+
return fetch(input, proxied)
98+
},
99+
})
100+
}
101+
102+
override handle(...args: Parameters<FetchHttpHandler['handle']>) {
103+
const [request, options] = args
104+
const port = request.port ? `:${request.port}` : ''
105+
const target = new URL(`${request.protocol}//${request.hostname}${port}`)
106+
if (resolveEnvironmentProxy(target)) return super.handle(request, options)
107+
return this.direct.handle(request, options)
108+
}
109+
110+
override destroy() {
111+
this.direct.destroy()
112+
super.destroy()
113+
}
114+
}
75115

76116
/**
77117
* The SDK's default Node handler ignores `HTTPS_PROXY`, so a container whose only egress is a
78-
* proxy could not fetch its config and would never boot. While a proxy is set under Bun (both
79-
* container bootstraps run on Bun), requests go through Bun's `fetch`, which honors
80-
* `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. The app's `awsRequestHandler` lives in `apps/sim`,
81-
* which packages cannot import, and the realtime bootstrap needs this path too. Undefined
82-
* otherwise, leaving the SDK default in place.
118+
* proxy could not fetch its config and would never boot. Both container bootstraps run on Bun,
119+
* so while a proxy is set this returns an {@link EnvironmentProxyHandler}. The app's
120+
* `awsRequestHandler` lives in `apps/sim`, which packages cannot import. Undefined otherwise,
121+
* leaving the SDK default in place.
83122
*/
84-
function proxyAwareRequestHandler(): FetchHttpHandler | undefined {
85-
if (!process.versions.bun) return undefined
86-
if (!PROXY_ENV.some((name) => process.env[name]?.trim())) return undefined
87-
return new FetchHttpHandler()
123+
function proxyAwareRequestHandler(): EnvironmentProxyHandler | undefined {
124+
if (!process.versions.bun || !isEnvironmentProxyConfigured()) return undefined
125+
return new EnvironmentProxyHandler()
88126
}
89127

90128
async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise<string> {

‎packages/runtime-secrets/src/proxy-egress.test.ts‎

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createServer, type Server } from 'node:http'
33
import type { AddressInfo } from 'node:net'
44
import { fileURLToPath } from 'node:url'
55
import { promisify } from 'node:util'
6-
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
77

88
const tunnels: string[] = []
99
let proxy: Server
@@ -20,39 +20,55 @@ beforeAll(async () => {
2020
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`
2121
})
2222

23+
beforeEach(() => {
24+
tunnels.length = 0
25+
})
26+
2327
afterAll(async () => {
2428
proxy.closeAllConnections()
2529
await new Promise<void>((resolve) => proxy.close(() => resolve()))
2630
})
2731

32+
/** Runs `loadRuntimeSecrets` in a real Bun process against a Secrets Manager endpoint only the proxy could reach. */
33+
function loadUnderBun(proxyEnv: Record<string, string>) {
34+
const entry = fileURLToPath(new URL('./index.ts', import.meta.url))
35+
return promisify(execFile)(
36+
'bun',
37+
['--no-env-file', '-e', `await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`],
38+
{
39+
timeout: 20_000,
40+
env: {
41+
PATH: process.env.PATH,
42+
HOME: process.env.HOME,
43+
SIM_ENV_SECRET_ID: '/test/sim/env-vars',
44+
AWS_REGION: 'us-east-1',
45+
AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE',
46+
AWS_SECRET_ACCESS_KEY: 'secret',
47+
AWS_CONFIG_FILE: '/dev/null',
48+
AWS_SHARED_CREDENTIALS_FILE: '/dev/null',
49+
AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid',
50+
...proxyEnv,
51+
},
52+
}
53+
)
54+
}
55+
2856
describe('loadRuntimeSecrets under a proxy-only egress', () => {
29-
it('reaches Secrets Manager through HTTPS_PROXY under the Bun runtime', async () => {
30-
const entry = fileURLToPath(new URL('./index.ts', import.meta.url))
31-
const run = promisify(execFile)(
32-
'bun',
33-
[
34-
'--no-env-file',
35-
'-e',
36-
`await (await import(${JSON.stringify(entry)})).loadRuntimeSecrets()`,
37-
],
38-
{
39-
timeout: 20_000,
40-
env: {
41-
PATH: process.env.PATH,
42-
HOME: process.env.HOME,
43-
SIM_ENV_SECRET_ID: '/test/sim/env-vars',
44-
AWS_REGION: 'us-east-1',
45-
AWS_ACCESS_KEY_ID: 'AKIDEXAMPLE',
46-
AWS_SECRET_ACCESS_KEY: 'secret',
47-
AWS_CONFIG_FILE: '/dev/null',
48-
AWS_SHARED_CREDENTIALS_FILE: '/dev/null',
49-
AWS_ENDPOINT_URL_SECRETS_MANAGER: 'https://secrets.invalid',
50-
HTTPS_PROXY: proxyUrl,
51-
NO_PROXY: '',
52-
},
53-
}
57+
it.each(['HTTPS_PROXY', 'HTTP_PROXY'])(
58+
'reaches Secrets Manager through %s under the Bun runtime',
59+
async (name) => {
60+
await expect(loadUnderBun({ [name]: proxyUrl })).rejects.toThrow(
61+
/Failed to fetch runtime secrets/
62+
)
63+
expect(tunnels).toContain('secrets.invalid:443')
64+
},
65+
30_000
66+
)
67+
68+
it('connects directly when NO_PROXY names the Secrets Manager host', async () => {
69+
await expect(loadUnderBun({ HTTP_PROXY: proxyUrl, NO_PROXY: '.invalid' })).rejects.toThrow(
70+
/Failed to fetch runtime secrets/
5471
)
55-
await expect(run).rejects.toThrow(/Failed to fetch runtime secrets/)
56-
expect(tunnels).toContain('secrets.invalid:443')
72+
expect(tunnels).toEqual([])
5773
}, 30_000)
5874
})

‎packages/security/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"types": "./src/encryption.ts",
2727
"default": "./src/encryption.ts"
2828
},
29+
"./environment-proxy": {
30+
"types": "./src/environment-proxy.ts",
31+
"default": "./src/environment-proxy.ts"
32+
},
2933
"./hash": {
3034
"types": "./src/hash.ts",
3135
"default": "./src/hash.ts"

apps/sim/lib/core/network/environment-proxy.server.ts renamed to packages/security/src/environment-proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { BlockList, isIP } from 'node:net'
2-
import { unwrapIpv6Brackets } from '@sim/security/hostnames'
2+
import { unwrapIpv6Brackets } from './hostnames'
33

44
const DEFAULT_PORTS: Record<string, number> = { 'http:': 80, 'https:': 443 }
55

0 commit comments

Comments
 (0)