| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- import { spawnSync } from 'node:child_process'
- import { createServer, type Server } from 'node:http'
- import type { AddressInfo } from 'node:net'
- import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest'
- import { getGlobalDispatcher } from 'undici'
- import {
- clearedProxyEnv,
- installProxyFromEnvironment,
- proxyEnvironmentForChild,
- proxyRouteFor,
- } from '../src/index.ts'
- import { PROXY_ENV_NAMES } from '../src/policy.ts'
- /** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */
- let proxied: string[] = []
- let proxy: Server
- let origin: Server
- let proxyUrl: string
- let originUrl: string
- /**
- * The target for every assertion about a tunnelled hop. It is deliberately not loopback: no policy
- * routes this machine through a proxy, so a loopback target could only ever prove a direct hop. The
- * host never resolves — the client connects to the proxy, which answers the absolute-form request.
- */
- const proxyTarget = 'http://origin.test/probe'
- function listen(server: Server): Promise<AddressInfo> {
- return new Promise((resolve) => {
- server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) })
- })
- }
- function close(server: Server): Promise<void> {
- return new Promise((resolve) => { server.close(() => { resolve() }) })
- }
- beforeAll(async () => {
- proxy = createServer((request, response) => {
- proxied.push(`${request.method} ${request.url}`)
- response.writeHead(200, { 'content-type': 'text/plain' })
- response.end('VIA-PROXY')
- })
- proxy.on('connect', (request, socket) => {
- proxied.push(`CONNECT ${request.url ?? ''}`)
- socket.end()
- })
- origin = createServer((_request, response) => { response.end('DIRECT') })
- const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)])
- proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}`
- originUrl = `http://127.0.0.1:${String(originAddress.port)}/probe`
- })
- afterAll(async () => {
- await Promise.all([close(proxy), close(origin)])
- })
- afterEach(() => {
- proxied = []
- })
- /** A second proxy URL, never dialed: it only has to differ from {@link proxyUrl} in an assertion. */
- const nestedUrl = 'http://127.0.0.1:9'
- /** A launch environment built from the names a user would export, in the casings they wrote. */
- function env(values: Record<string, string>): { get(name: string): { value: string } | undefined } {
- return { get: name => (name in values ? { value: values[name] as string } : undefined) }
- }
- /** The environment of a user who exported one proxy for both schemes. */
- function proxyAll(noProxy?: string): { get(name: string): { value: string } | undefined } {
- return env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, ...noProxy === undefined ? {} : { NO_PROXY: noProxy } })
- }
- /** Install and collect whatever the resolution reported, so a case can assert on both. */
- async function install(
- lookup: { get(name: string): { value: string } | undefined },
- ): Promise<{ dispose: () => Promise<void>; reported: string[] }> {
- const reported: string[] = []
- const dispose = await installProxyFromEnvironment(lookup, (message) => { reported.push(message) })
- return { dispose, reported }
- }
- /** Run one case from a known-empty proxy environment, then restore what the machine had. */
- async function withCleanProxyEnv(run: () => Promise<void>): Promise<void> {
- const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
- for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
- try {
- await run()
- } finally {
- for (const [name, value] of Object.entries(saved)) {
- if (value === undefined) Reflect.deleteProperty(process.env, name)
- else process.env[name] = value
- }
- }
- }
- describe('installProxyFromEnvironment', () => {
- it('routes the built-in global fetch through the proxy', async () => {
- const { dispose } = await install(proxyAll())
- try {
- await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
- expect(proxied).toEqual([`GET ${proxyTarget}`])
- } finally {
- await dispose()
- }
- })
- it('connects directly when the bypass list covers the target', async () => {
- const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, NO_PROXY: 'origin.test' }))
- try {
- await expect(fetch(proxyTarget, { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
- expect(proxied).toEqual([])
- } finally {
- await dispose()
- }
- })
- it('reports a value it cannot use and installs the rest', async () => {
- const { dispose, reported } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
- try {
- // A variable exported for another tool must not stop the agent from starting, and the user
- // has to learn that this scheme stays direct rather than discover it from a failing request.
- // The message names the variable, never its value: a proxy URL may carry `user:password`.
- expect(reported).toHaveLength(1)
- expect(reported[0]).toContain('HTTPS_PROXY')
- expect(reported[0]).toContain('SOCKS')
- expect(reported[0]).not.toContain('1080')
- await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
- } finally {
- await dispose()
- }
- })
- it('publishes the policy through the proxy environment in both casings', async () => {
- const { dispose } = await install(proxyAll('example.com'))
- try {
- expect(process.env.http_proxy).toBe(proxyUrl)
- expect(process.env.HTTP_PROXY).toBe(proxyUrl)
- expect(process.env.no_proxy).toContain('example.com')
- expect(process.env.NO_PROXY).toContain('example.com')
- } finally {
- await dispose()
- }
- })
- it('removes an environment name the policy leaves unset', async () => {
- process.env.HTTPS_PROXY = 'http://stale.example'
- // The user named no HTTPS proxy, so the policy derives one from HTTP — the name is rewritten,
- // never left carrying a value from an earlier process.
- const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
- try {
- expect(process.env.HTTPS_PROXY).toBeUndefined()
- } finally {
- await dispose()
- expect(process.env.HTTPS_PROXY).toBe('http://stale.example')
- delete process.env.HTTPS_PROXY
- }
- })
- it('restores the dispatcher, the route, and the environment on disposal', async () => {
- const before = getGlobalDispatcher()
- const beforeEnv = process.env.HTTP_PROXY
- const { dispose } = await install(proxyAll())
- expect(getGlobalDispatcher()).not.toBe(before)
- expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
- await dispose()
- expect(getGlobalDispatcher()).toBe(before)
- expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(false)
- expect(process.env.HTTP_PROXY).toBe(beforeEnv)
- await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
- })
- it('installs no dispatcher and touches no environment when the user exported none', async () => {
- const before = getGlobalDispatcher()
- process.env.HTTP_PROXY = 'http://untouched.example'
- const { dispose, reported } = await install(env({}))
- try {
- expect(getGlobalDispatcher()).toBe(before)
- expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
- expect(reported).toEqual([])
- expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
- } finally {
- await dispose()
- delete process.env.HTTP_PROXY
- }
- })
- it('keeps a scheme direct when the policy refused the proxy the user named for it', async () => {
- // What `HTTPS_PROXY=socks5://…` plus `HTTP_PROXY=http://p` resolves to: http proxied, https
- // direct. undici's own EnvHttpProxyAgent cannot express this — with no HTTPS proxy present it
- // reuses the HTTP one, tunnelling the scheme the diagnostic told the user stayed direct.
- const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
- try {
- // The direct path here fails on a DNS miss whose latency is the machine's resolver to decide;
- // the deadline bounds it. Either rejection proves the same thing — no CONNECT reached the
- // proxy — and a proxied hop would have answered in milliseconds instead.
- await expect(fetch('https://refused-scheme.invalid/', { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
- expect(proxied).toEqual([])
- // The same policy still tunnels http, so the empty expectation above is not vacuous.
- await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
- expect(proxied).toEqual([`GET ${proxyTarget}`])
- } finally {
- await dispose()
- }
- })
- })
- describe('proxyRouteFor', () => {
- it('carries the dispatcher already routing, so a branch and its request agree', async () => {
- const { dispose } = await install(proxyAll())
- const route = proxyRouteFor(new URL(proxyTarget))
- expect(route).toMatchObject({ proxied: true, proxy: proxyUrl })
- if (!route.proxied) throw new Error('unreachable: asserted proxied above')
- // One transport, not a copy: a caller that branched on this route sends its request through the
- // very agent the branch described, so no second read can put the two on different routes.
- expect(route.dispatcher).toBe(getGlobalDispatcher())
- const undici = await import('undici')
- // Disposing the install while a request is in flight: the shared dispatcher is closed, not
- // destroyed, so the hop that already left finishes.
- const inFlight = undici.fetch(proxyTarget, { dispatcher: route.dispatcher })
- await dispose()
- await expect((await inFlight).text()).resolves.toBe('VIA-PROXY')
- expect(proxied).toEqual([`GET ${proxyTarget}`])
- })
- it('is direct for a bypassed URL, and direct with nothing installed', async () => {
- const { dispose } = await install(proxyAll('origin.test'))
- try {
- expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
- } finally {
- await dispose()
- }
- expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
- })
- it('is direct for a loopback URL under a policy that proxies everything', async () => {
- const { dispose } = await install(proxyAll())
- try {
- expect(proxyRouteFor(new URL(originUrl))).toEqual({ proxied: false })
- } finally {
- await dispose()
- }
- })
- })
- describe('proxyEnvironmentForChild', () => {
- it('is empty when no policy is installed', () => {
- expect(proxyEnvironmentForChild()).toEqual({})
- })
- it('is empty when the user exported none, so a child sees no flag it cannot use', async () => {
- const { dispose } = await install(env({}))
- try {
- expect(proxyEnvironmentForChild()).toEqual({})
- } finally {
- await dispose()
- }
- })
- it('hands a child the values the user exported, not this process\'s normalization', async () => {
- await withCleanProxyEnv(async () => {
- // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses.
- process.env.HTTP_PROXY = proxyUrl
- process.env.https_proxy = 'socks5://127.0.0.1:1080'
- const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080', NO_PROXY: 'example.com' }))
- try {
- const child = proxyEnvironmentForChild()
- // The published policy derived an HTTPS proxy for this process; the child must not see it.
- // Asserted over both casings rather than one: Windows folds the pair into a single variable,
- // so which spelling carries the value is the platform's to decide — that it is the user's
- // value and never the derived one is not.
- const https = [child.https_proxy, child.HTTPS_PROXY]
- expect(https).toContain('socks5://127.0.0.1:1080')
- expect(https).not.toContain(proxyUrl)
- expect(child.HTTP_PROXY).toBe(proxyUrl)
- // The bypass list is the resolved one: it only adds entries to what the user wrote, and
- // without the loopback ones the child sends its own localhost traffic to a proxy that
- // cannot route it.
- expect(child.no_proxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
- expect(child.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
- // The SOCKS value kept for `curl` is one Node would refuse at startup, so the flag that makes
- // Node read it is withheld and a child Node connects directly rather than failing to start.
- expect(child.NODE_USE_ENV_PROXY).toBeUndefined()
- } finally {
- await dispose()
- }
- })
- })
- it('fills a scheme the user named in neither casing, so a child Node is not left direct', async () => {
- await withCleanProxyEnv(async () => {
- // The user exported only ALL_PROXY. `NODE_USE_ENV_PROXY` never reads that name, so a child
- // Node would connect directly while this process proxies — the seam this fill closes.
- process.env.ALL_PROXY = proxyUrl
- const { dispose } = await install(env({ ALL_PROXY: proxyUrl }))
- try {
- const child = proxyEnvironmentForChild()
- expect(child.HTTP_PROXY).toBe(proxyUrl)
- expect(child.http_proxy).toBe(proxyUrl)
- expect(child.HTTPS_PROXY).toBe(proxyUrl)
- expect(child.https_proxy).toBe(proxyUrl)
- expect(child.NODE_USE_ENV_PROXY).toBe('1')
- } finally {
- await dispose()
- }
- })
- })
- it.each(['socks4://127.0.0.1:1080', 'ftp://p:1', 'not a url'])(
- 'withholds NODE_USE_ENV_PROXY when the child receives %s, so a child Node still starts',
- async (refused) => {
- await withCleanProxyEnv(async () => {
- process.env.HTTP_PROXY = proxyUrl
- process.env.HTTPS_PROXY = refused
- const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: refused }))
- try {
- const child = proxyEnvironmentForChild()
- // The value is still handed over — `curl` may read it — but Node, which parses these two
- // names before running anything under the flag, must not be told to.
- expect(child.HTTPS_PROXY).toBe(refused)
- expect(child.HTTP_PROXY).toBe(proxyUrl)
- expect(child).not.toHaveProperty('NODE_USE_ENV_PROXY')
- // Proved on a real child rather than inferred: the same environment with the flag present
- // exits before the program runs, on every Node this repository supports.
- const childEnv: Record<string, string> = { PATH: process.env.PATH ?? '' }
- for (const [name, value] of Object.entries(child)) if (value !== undefined) childEnv[name] = value
- const run = spawnSync(process.execPath, ['-e', 'process.stdout.write("started")'], { env: childEnv, encoding: 'utf8' })
- expect({ status: run.status, stdout: run.stdout }).toEqual({ status: 0, stdout: 'started' })
- } finally {
- await dispose()
- }
- })
- },
- )
- it('keeps the outermost install\'s record of what the user exported across a nested one', async () => {
- await withCleanProxyEnv(async () => {
- // The user exported one name, in one casing.
- process.env.HTTP_PROXY = proxyUrl
- // The launcher installs first; a second `installProxyFromEnvironment` layers another policy over it.
- const outer = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: 'example.com' }))
- try {
- const inner = await install(env({ HTTP_PROXY: nestedUrl, HTTPS_PROXY: nestedUrl }))
- try {
- const child = proxyEnvironmentForChild()
- // The user named no HTTPS proxy, so this scheme carries whichever policy is active. Reading
- // the outer install's published environment as the user's would pin it to the outer proxy
- // instead — the one discriminator that does not depend on how a platform cases names.
- expect(child.https_proxy).toBe(nestedUrl)
- expect(child.HTTPS_PROXY).toBe(nestedUrl)
- } finally {
- await inner.dispose()
- }
- // Unmounting the inner install must leave the outer one still able to describe that
- // environment; clearing the record instead makes this an empty object, so every later child
- // inherits the normalized values from `process.env` untouched.
- expect(proxyEnvironmentForChild().HTTP_PROXY).toBe(proxyUrl)
- expect(proxyEnvironmentForChild().https_proxy).toBe(proxyUrl)
- } finally {
- await outer.dispose()
- }
- })
- })
- })
- describe('installing over an existing installation', () => {
- it('stops proxying when the mounted policy proxies nothing', async () => {
- const outer = await install(proxyAll())
- try {
- await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
- const off = await install(env({}))
- try {
- // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the
- // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one
- // reaches the real origin rather than the name only the proxy can resolve.
- await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
- expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
- } finally {
- await off.dispose()
- }
- // Disposing the direct policy restores the proxy the launcher installed.
- await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
- expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
- } finally {
- await outer.dispose()
- }
- })
- })
- describe('the environment while a direct policy is layered over a proxied one', () => {
- it('hands a child the user\'s own values, and the outer normalization again afterwards', async () => {
- await withCleanProxyEnv(async () => {
- // The user exported one usable proxy and one this package refuses.
- process.env.HTTP_PROXY = proxyUrl
- process.env.https_proxy = 'socks5://127.0.0.1:1080'
- const outer = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }))
- try {
- // The outer install published its policy: the refused scheme is removed in both casings.
- expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
- const off = await install(env({}))
- try {
- // A spawned child copies `process.env`, and `proxyEnvironmentForChild()` adds nothing under a
- // direct policy — so what it copies has to be the user's own environment, not a normalization
- // no active policy stands behind: the SOCKS value they set for `curl` is theirs again.
- expect(process.env.HTTP_PROXY).toBe(proxyUrl)
- expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toContain('socks5://127.0.0.1:1080')
- expect(proxyEnvironmentForChild()).toEqual({})
- } finally {
- await off.dispose()
- }
- // Ending the window re-applies what the outer install published.
- expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
- expect(process.env.HTTP_PROXY).toBe(proxyUrl)
- } finally {
- await outer.dispose()
- }
- })
- })
- it('touches no environment when the install underneath proxied nothing', async () => {
- process.env.HTTP_PROXY = 'http://untouched.example'
- const outer = await install(env({}))
- const inner = await install(env({}))
- try {
- expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
- } finally {
- await inner.dispose()
- await outer.dispose()
- expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
- delete process.env.HTTP_PROXY
- }
- })
- })
- describe('the published environment', () => {
- it('restores every name from one snapshot taken before any write', async () => {
- process.env.http_proxy = 'http://before.example'
- process.env.HTTP_PROXY = 'http://before.example'
- const { dispose } = await install(proxyAll())
- expect(process.env.HTTP_PROXY).toBe(proxyUrl)
- await dispose()
- // Reading the uppercase spelling after writing the lowercase one must not restore the value
- // just written — the failure Windows's case-folded environment would produce.
- expect(process.env.http_proxy).toBe('http://before.example')
- expect(process.env.HTTP_PROXY).toBe('http://before.example')
- delete process.env.http_proxy
- delete process.env.HTTP_PROXY
- })
- })
- describe('clearedProxyEnv', () => {
- it('names every proxy variable for removal, so a replay reaches its own fixture server', () => {
- const cleared = clearedProxyEnv()
- expect(Object.keys(cleared).sort()).toEqual([...PROXY_ENV_NAMES].sort())
- expect(Object.values(cleared).every(value => value === undefined)).toBe(true)
- })
- })
|