Skip to content

Commit 76e8618

Browse files
committed
fix(network): match NO_PROXY entries like curl, including bare IPv6 and CIDR
A bare IPv6 entry such as ::1 was read as host ':' port 1 and never matched, IP entries only matched their exact spelling, and CIDR ranges were ignored. Entries now follow curl: domains cover their subdomains, bracketed IPv6 may carry a port, IP entries match any spelling, and CIDR ranges match IP-literal destinations. NO_PROXY is parsed once per distinct value. The comments and the self-hosting networking page describe these rules, and note that Bun's default fetch applies its own.
1 parent b96b952 commit 76e8618

3 files changed

Lines changed: 141 additions & 13 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ 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, applies Bun's own `NO_PROXY` rules, which may not honor CIDR or bare IPv6 entries, so list those destinations by hostname.
305+
304306
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:
305307
306308
```yaml

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,66 @@ describe('proxy selection from the environment', () => {
323323
target: 'https://api.example.com/',
324324
proxy: 'http://p:3128/',
325325
},
326+
{
327+
case: 'a bare NO_PROXY domain also excludes its subdomains',
328+
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' },
329+
target: 'https://api.example.com/',
330+
proxy: null,
331+
},
332+
{
333+
case: 'a leading-dot NO_PROXY domain also excludes the apex',
334+
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '.example.com' },
335+
target: 'https://example.com/',
336+
proxy: null,
337+
},
338+
{
339+
case: 'a NO_PROXY domain does not match a longer name that merely ends with it',
340+
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: 'example.com' },
341+
target: 'https://notexample.com/',
342+
proxy: 'http://p:3128/',
343+
},
344+
{
345+
case: 'a bare IPv6 NO_PROXY entry excludes that address',
346+
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'localhost,::1' },
347+
target: 'http://[::1]:3000/',
348+
proxy: null,
349+
},
350+
{
351+
case: 'an IPv6 NO_PROXY entry matches any spelling of the address',
352+
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '0:0:0:0:0:0:0:1' },
353+
target: 'http://[::1]/',
354+
proxy: null,
355+
},
356+
{
357+
case: 'a bracketed IPv6 NO_PROXY entry with a port only excludes that port',
358+
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: '[::1]:3000' },
359+
target: 'http://[::1]:4000/',
360+
proxy: 'http://p:3128/',
361+
},
362+
{
363+
case: 'an IPv4 CIDR NO_PROXY entry excludes addresses inside the range',
364+
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' },
365+
target: 'https://10.20.30.40/',
366+
proxy: null,
367+
},
368+
{
369+
case: 'an IPv4 CIDR NO_PROXY entry leaves addresses outside the range proxied',
370+
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '10.0.0.0/8' },
371+
target: 'https://11.0.0.1/',
372+
proxy: 'http://p:3128/',
373+
},
374+
{
375+
case: 'an IPv6 CIDR NO_PROXY entry excludes addresses inside the range',
376+
env: { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'fd00::/8' },
377+
target: 'http://[fd12::1]/',
378+
proxy: null,
379+
},
380+
{
381+
case: 'an empty lowercase no_proxy does not hide the uppercase one',
382+
env: { HTTPS_PROXY: 'http://p:3128', no_proxy: '', NO_PROXY: 'example.com' },
383+
target: 'https://example.com/',
384+
proxy: null,
385+
},
326386
{
327387
case: 'NO_PROXY=* excludes every destination',
328388
env: { HTTPS_PROXY: 'http://p:3128', NO_PROXY: '*' },

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

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import { BlockList, isIP } from 'node:net'
2+
import { unwrapIpv6Brackets } from '@sim/security/hostnames'
3+
14
const DEFAULT_PORTS: Record<string, number> = { 'http:': 80, 'https:': 443 }
25

3-
/** An empty lowercase variable counts as unset, so it cannot hide the uppercase one. */
6+
/**
7+
* Lowercase wins when set, but an empty lowercase variable counts as unset, so it cannot hide
8+
* the uppercase one (undici's `EnvHttpProxyAgent` lets an empty `no_proxy` hide `NO_PROXY`).
9+
*/
410
function readEnv(lower: string, upper: string): string | undefined {
511
return process.env[lower]?.trim() || process.env[upper]?.trim() || undefined
612
}
@@ -10,27 +16,87 @@ export function isEnvironmentProxyConfigured(): boolean {
1016
return Boolean(readEnv('https_proxy', 'HTTPS_PROXY') ?? readEnv('http_proxy', 'HTTP_PROXY'))
1117
}
1218

13-
/** Matches `NO_PROXY` the way undici's `EnvHttpProxyAgent` does, so both stacks agree. */
19+
type NoProxyRule =
20+
| { kind: 'domain'; domain: string; port: number }
21+
| { kind: 'address'; family: 'ipv4' | 'ipv6'; addresses: BlockList; port: number }
22+
23+
function ipFamily(address: string): 'ipv4' | 'ipv6' | null {
24+
const version = isIP(address)
25+
return version === 4 ? 'ipv4' : version === 6 ? 'ipv6' : null
26+
}
27+
28+
/**
29+
* Parses one `NO_PROXY` entry, or null when it can never match. A bracketed IPv6 address may
30+
* carry a port (`[::1]:8080`); a bare one (`::1`) never does, since its last group reads as one.
31+
*/
32+
function parseNoProxyEntry(entry: string): NoProxyRule | null {
33+
let host = entry
34+
let port = 0
35+
const bracketed = entry.match(/^\[([^\]]+)\](?::(\d+))?$/)
36+
const withPort = entry.match(/^(.+):(\d+)$/)
37+
if (bracketed) {
38+
host = bracketed[1]
39+
port = bracketed[2] ? Number(bracketed[2]) : 0
40+
} else if (withPort && isIP(entry.split('/')[0]) !== 6) {
41+
host = withPort[1]
42+
port = Number(withPort[2])
43+
}
44+
const [address, prefix] = host.split('/')
45+
const family = ipFamily(address)
46+
if (!family) return { kind: 'domain', domain: host.replace(/^\*?\./, '').toLowerCase(), port }
47+
const addresses = new BlockList()
48+
try {
49+
if (prefix === undefined) addresses.addAddress(address, family)
50+
else if (/^\d+$/.test(prefix)) addresses.addSubnet(address, Number(prefix), family)
51+
else return null
52+
} catch {
53+
return null
54+
}
55+
return { kind: 'address', family, addresses, port }
56+
}
57+
58+
/** The last parsed `NO_PROXY`, so each request reuses its rules until the value changes. */
59+
let parsedNoProxy: { raw: string; rules: NoProxyRule[] } | null = null
60+
61+
function noProxyRules(raw: string): NoProxyRule[] {
62+
if (parsedNoProxy?.raw !== raw) {
63+
const rules = raw
64+
.split(/[,\s]/)
65+
.filter(Boolean)
66+
.map(parseNoProxyEntry)
67+
.filter((rule): rule is NoProxyRule => rule !== null)
68+
parsedNoProxy = { raw, rules }
69+
}
70+
return parsedNoProxy.rules
71+
}
72+
73+
/**
74+
* Whether `NO_PROXY` sends this destination direct, with curl's matching: `*` matches
75+
* everything; `example.com`, `.example.com` and `*.example.com` all match the domain and
76+
* every subdomain; `host:port` and `[v6]:port` match only that port; IP entries match any
77+
* spelling of the address; and `10.0.0.0/8` or `fd00::/8` match IP-literal destinations in
78+
* the range (never a hostname, which is not resolved here).
79+
*/
1480
function isExcludedByNoProxy(hostname: string, port: number): boolean {
1581
const noProxy = readEnv('no_proxy', 'NO_PROXY')
1682
if (!noProxy) return false
1783
if (noProxy === '*') return true
18-
for (const entry of noProxy.split(/[,\s]/)) {
19-
if (!entry) continue
20-
const withPort = entry.match(/^(.+):(\d+)$/)
21-
const entryHost = (withPort ? withPort[1] : entry).replace(/^\*?\./, '').toLowerCase()
22-
const entryPort = withPort ? Number.parseInt(withPort[2], 10) : 0
23-
if (entryPort && entryPort !== port) continue
24-
if (hostname === entryHost || hostname.endsWith(`.${entryHost}`)) return true
25-
}
26-
return false
84+
const host = unwrapIpv6Brackets(hostname.toLowerCase())
85+
const hostFamily = ipFamily(host)
86+
return noProxyRules(noProxy).some((rule) => {
87+
if (rule.port && rule.port !== port) return false
88+
if (rule.kind === 'address') {
89+
return hostFamily === rule.family && rule.addresses.check(host, hostFamily)
90+
}
91+
return host === rule.domain || host.endsWith(`.${rule.domain}`)
92+
})
2793
}
2894

2995
/**
3096
* The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies.
3197
* Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY`
32-
* excludes hosts with undici's `EnvHttpProxyAgent` matching, so guarded requests leave the
33-
* way the default `fetch` does. Read per call so a value set after startup is honored.
98+
* excludes hosts as {@link isExcludedByNoProxy} describes. Read per call so a value set
99+
* after startup is honored.
34100
*/
35101
export function resolveEnvironmentProxy(target: URL): string | null {
36102
const defaultPort = DEFAULT_PORTS[target.protocol]

0 commit comments

Comments
 (0)