Skip to content

Commit b313325

Browse files
committed
fix(network): limit environment proxies to http and https, cover an https proxy
1 parent c6a112f commit b313325

3 files changed

Lines changed: 32 additions & 16 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
@@ -294,7 +294,7 @@ A request through the SSRF guard keeps its checks when it is proxied. Before eac
294294
- **DNS must still resolve inside the container.** A destination that the app cannot resolve is refused before the proxy is asked.
295295
- **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.
296296
- **`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.
297+
- **Proxy credentials** go in the URL, e.g. `http://user:pass@proxy.internal:3128`. They are sent only to the proxy. An `https://` proxy URL also works; SOCKS proxies are not supported.
298298
- **The HTTP block's `proxyUrl`** still takes precedence for that one request.
299299
300300
### Internal destinations and `NO_PROXY`

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { execFile } from 'node:child_process'
22
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3-
import { createServer as httpServer } from 'node:http'
3+
import { createServer as httpServer, type IncomingMessage, type ServerResponse } from 'node:http'
44
import { createServer as httpsServer } from 'node:https'
55
import { type AddressInfo, connect, type Socket } from 'node:net'
66
import { tmpdir } from 'node:os'
77
import { join } from 'node:path'
8+
import type { Duplex } from 'node:stream'
89
import { getCACertificates, setDefaultCACertificates } from 'node:tls'
910
import { fileURLToPath } from 'node:url'
1011
import { promisify } from 'node:util'
@@ -55,12 +56,12 @@ const secureOrigin = httpsServer((req, res) => {
5556
res.end('tls reached')
5657
})
5758

58-
/** A plain forward proxy: CONNECT tunnels reach the TLS origin, absolute-form requests are answered here. */
59-
const proxy = httpServer((req, res) => {
59+
/** A forward proxy: CONNECT tunnels reach the TLS origin, absolute-form requests are answered here. */
60+
function forwardRequest(req: IncomingMessage, res: ServerResponse) {
6061
forwarded.push({ url: req.url, authorization: req.headers['proxy-authorization'] })
6162
res.end('forwarded')
62-
})
63-
proxy.on('connect', (req, socket, head) => {
63+
}
64+
function tunnel(req: IncomingMessage, socket: Duplex, head: Buffer) {
6465
socket.on('error', () => {})
6566
tunnels.push({ authority: req.url, authorization: req.headers['proxy-authorization'] })
6667
const upstream = connect(securePort, '127.0.0.1', () => {
@@ -73,10 +74,15 @@ proxy.on('connect', (req, socket, head) => {
7374
socket.on('close', () => upstream.destroy())
7475
sockets.add(upstream)
7576
upstream.on('close', () => sockets.delete(upstream))
76-
})
77+
}
78+
const proxy = httpServer(forwardRequest)
79+
proxy.on('connect', tunnel)
80+
const tlsProxy = httpsServer(forwardRequest)
81+
tlsProxy.on('connect', tunnel)
7782

7883
let securePort = 0
7984
let proxyUrl = ''
85+
let tlsProxyUrl = ''
8086
const trust = getCACertificates('default')
8187
const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy']
8288
const options = { profile: 'contentFetch' as const, timeout: 10_000 }
@@ -98,14 +104,15 @@ beforeAll(async () => {
98104
'-subj',
99105
'/CN=origin.invalid',
100106
'-addext',
101-
'subjectAltName=DNS:origin.invalid,IP:1.1.1.1',
107+
'subjectAltName=DNS:origin.invalid,IP:1.1.1.1,IP:127.0.0.1',
102108
'-addext',
103109
'extendedKeyUsage=serverAuth',
104110
])
105111
const cert = readFileSync(certificatePath, 'utf8')
106-
secureOrigin.setSecureContext({ cert, key: readFileSync(keyPath, 'utf8') })
112+
const key = readFileSync(keyPath, 'utf8')
113+
for (const server of [secureOrigin, tlsProxy]) server.setSecureContext({ cert, key })
107114
setDefaultCACertificates([...trust, cert])
108-
for (const server of [secureOrigin, proxy]) {
115+
for (const server of [secureOrigin, proxy, tlsProxy]) {
109116
server.on('connection', (socket) => {
110117
sockets.add(socket)
111118
socket.on('close', () => sockets.delete(socket))
@@ -114,6 +121,7 @@ beforeAll(async () => {
114121
}
115122
securePort = (secureOrigin.address() as AddressInfo).port
116123
proxyUrl = `http://sim:proxy-secret@127.0.0.1:${(proxy.address() as AddressInfo).port}`
124+
tlsProxyUrl = `https://sim:proxy-secret@127.0.0.1:${(tlsProxy.address() as AddressInfo).port}`
117125
})
118126

119127
beforeEach(() => {
@@ -132,7 +140,7 @@ afterEach(() => {
132140
afterAll(async () => {
133141
for (const socket of sockets) socket.destroy()
134142
await Promise.all(
135-
[secureOrigin, proxy].map(
143+
[secureOrigin, proxy, tlsProxy].map(
136144
(server) => new Promise<void>((resolve) => server.close(() => resolve()))
137145
)
138146
)
@@ -172,6 +180,15 @@ describe('guarded requests under HTTPS_PROXY', () => {
172180
}
173181
})
174182

183+
it('tunnels through an https:// proxy, verifying the proxy certificate', async () => {
184+
vi.stubEnv('HTTPS_PROXY', tlsProxyUrl)
185+
const response = await secureFetchWithValidation('https://origin.invalid/', options)
186+
expect(await response.text()).toBe('tls reached')
187+
expect(tunnels).toEqual([
188+
{ authority: 'origin.invalid:443', authorization: PROXY_AUTHORIZATION },
189+
])
190+
})
191+
175192
it('forwards plain-http destinations in absolute form instead of tunneling', async () => {
176193
const response = await secureFetchWithPinnedIP('http://origin.invalid/plain', '1.1.1.1', {
177194
...options,

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const DEFAULT_PORTS: Record<string, number> = { 'http:': 80, 'https:': 443 }
2-
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:', 'socks:', 'socks5:'])
2+
const SUPPORTED_PROXY_PROTOCOLS = new Set(['http:', 'https:'])
33

44
/** An empty lowercase variable counts as unset, so it cannot hide the uppercase one. */
55
function readEnv(lower: string, upper: string): string | undefined {
@@ -25,9 +25,8 @@ function isExcludedByNoProxy(hostname: string, port: number): boolean {
2525
/**
2626
* The operator's `HTTPS_PROXY` / `HTTP_PROXY` for a destination, or null when none applies.
2727
* Lowercase names win, an https destination falls back to `HTTP_PROXY`, and `NO_PROXY`
28-
* excludes hosts — the same rules Bun's `fetch` and undici's `EnvHttpProxyAgent` apply, so
29-
* guarded and unguarded requests leave the same way. Read per call so a value set after
30-
* startup is honored.
28+
* excludes hosts with undici's `EnvHttpProxyAgent` matching, so guarded requests leave the
29+
* way the default `fetch` does. Read per call so a value set after startup is honored.
3130
*/
3231
export function resolveEnvironmentProxy(target: URL): string | null {
3332
const defaultPort = DEFAULT_PORTS[target.protocol]
@@ -51,7 +50,7 @@ function normalizeProxyUrl(value: string): string {
5150
throw new Error('The configured HTTP(S)_PROXY is not a valid URL')
5251
}
5352
if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol)) {
54-
throw new Error('The configured HTTP(S)_PROXY must use http, https, or socks5')
53+
throw new Error('The configured HTTP(S)_PROXY must be an http:// or https:// URL')
5554
}
5655
return parsed.href
5756
}

0 commit comments

Comments
 (0)