install.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import { spawnSync } from 'node:child_process'
  2. import { createServer, type Server } from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest'
  5. import { getGlobalDispatcher } from 'undici'
  6. import {
  7. clearedProxyEnv,
  8. installProxyFromEnvironment,
  9. proxyEnvironmentForChild,
  10. proxyRouteFor,
  11. } from '../src/index.ts'
  12. import { PROXY_ENV_NAMES } from '../src/policy.ts'
  13. /** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */
  14. let proxied: string[] = []
  15. let proxy: Server
  16. let origin: Server
  17. let proxyUrl: string
  18. let originUrl: string
  19. /**
  20. * The target for every assertion about a tunnelled hop. It is deliberately not loopback: no policy
  21. * routes this machine through a proxy, so a loopback target could only ever prove a direct hop. The
  22. * host never resolves — the client connects to the proxy, which answers the absolute-form request.
  23. */
  24. const proxyTarget = 'http://origin.test/probe'
  25. function listen(server: Server): Promise<AddressInfo> {
  26. return new Promise((resolve) => {
  27. server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) })
  28. })
  29. }
  30. function close(server: Server): Promise<void> {
  31. return new Promise((resolve) => { server.close(() => { resolve() }) })
  32. }
  33. beforeAll(async () => {
  34. proxy = createServer((request, response) => {
  35. proxied.push(`${request.method} ${request.url}`)
  36. response.writeHead(200, { 'content-type': 'text/plain' })
  37. response.end('VIA-PROXY')
  38. })
  39. proxy.on('connect', (request, socket) => {
  40. proxied.push(`CONNECT ${request.url ?? ''}`)
  41. socket.end()
  42. })
  43. origin = createServer((_request, response) => { response.end('DIRECT') })
  44. const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)])
  45. proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}`
  46. originUrl = `http://127.0.0.1:${String(originAddress.port)}/probe`
  47. })
  48. afterAll(async () => {
  49. await Promise.all([close(proxy), close(origin)])
  50. })
  51. afterEach(() => {
  52. proxied = []
  53. })
  54. /** A second proxy URL, never dialed: it only has to differ from {@link proxyUrl} in an assertion. */
  55. const nestedUrl = 'http://127.0.0.1:9'
  56. /** A launch environment built from the names a user would export, in the casings they wrote. */
  57. function env(values: Record<string, string>): { get(name: string): { value: string } | undefined } {
  58. return { get: name => (name in values ? { value: values[name] as string } : undefined) }
  59. }
  60. /** The environment of a user who exported one proxy for both schemes. */
  61. function proxyAll(noProxy?: string): { get(name: string): { value: string } | undefined } {
  62. return env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, ...noProxy === undefined ? {} : { NO_PROXY: noProxy } })
  63. }
  64. /** Install and collect whatever the resolution reported, so a case can assert on both. */
  65. async function install(
  66. lookup: { get(name: string): { value: string } | undefined },
  67. ): Promise<{ dispose: () => Promise<void>; reported: string[] }> {
  68. const reported: string[] = []
  69. const dispose = await installProxyFromEnvironment(lookup, (message) => { reported.push(message) })
  70. return { dispose, reported }
  71. }
  72. /** Run one case from a known-empty proxy environment, then restore what the machine had. */
  73. async function withCleanProxyEnv(run: () => Promise<void>): Promise<void> {
  74. const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
  75. for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
  76. try {
  77. await run()
  78. } finally {
  79. for (const [name, value] of Object.entries(saved)) {
  80. if (value === undefined) Reflect.deleteProperty(process.env, name)
  81. else process.env[name] = value
  82. }
  83. }
  84. }
  85. describe('installProxyFromEnvironment', () => {
  86. it('routes the built-in global fetch through the proxy', async () => {
  87. const { dispose } = await install(proxyAll())
  88. try {
  89. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
  90. expect(proxied).toEqual([`GET ${proxyTarget}`])
  91. } finally {
  92. await dispose()
  93. }
  94. })
  95. it('connects directly when the bypass list covers the target', async () => {
  96. const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, NO_PROXY: 'origin.test' }))
  97. try {
  98. await expect(fetch(proxyTarget, { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
  99. expect(proxied).toEqual([])
  100. } finally {
  101. await dispose()
  102. }
  103. })
  104. it('reports a value it cannot use and installs the rest', async () => {
  105. const { dispose, reported } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
  106. try {
  107. // A variable exported for another tool must not stop the agent from starting, and the user
  108. // has to learn that this scheme stays direct rather than discover it from a failing request.
  109. // The message names the variable, never its value: a proxy URL may carry `user:password`.
  110. expect(reported).toHaveLength(1)
  111. expect(reported[0]).toContain('HTTPS_PROXY')
  112. expect(reported[0]).toContain('SOCKS')
  113. expect(reported[0]).not.toContain('1080')
  114. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
  115. } finally {
  116. await dispose()
  117. }
  118. })
  119. it('publishes the policy through the proxy environment in both casings', async () => {
  120. const { dispose } = await install(proxyAll('example.com'))
  121. try {
  122. expect(process.env.http_proxy).toBe(proxyUrl)
  123. expect(process.env.HTTP_PROXY).toBe(proxyUrl)
  124. expect(process.env.no_proxy).toContain('example.com')
  125. expect(process.env.NO_PROXY).toContain('example.com')
  126. } finally {
  127. await dispose()
  128. }
  129. })
  130. it('removes an environment name the policy leaves unset', async () => {
  131. process.env.HTTPS_PROXY = 'http://stale.example'
  132. // The user named no HTTPS proxy, so the policy derives one from HTTP — the name is rewritten,
  133. // never left carrying a value from an earlier process.
  134. const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
  135. try {
  136. expect(process.env.HTTPS_PROXY).toBeUndefined()
  137. } finally {
  138. await dispose()
  139. expect(process.env.HTTPS_PROXY).toBe('http://stale.example')
  140. delete process.env.HTTPS_PROXY
  141. }
  142. })
  143. it('restores the dispatcher, the route, and the environment on disposal', async () => {
  144. const before = getGlobalDispatcher()
  145. const beforeEnv = process.env.HTTP_PROXY
  146. const { dispose } = await install(proxyAll())
  147. expect(getGlobalDispatcher()).not.toBe(before)
  148. expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
  149. await dispose()
  150. expect(getGlobalDispatcher()).toBe(before)
  151. expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(false)
  152. expect(process.env.HTTP_PROXY).toBe(beforeEnv)
  153. await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
  154. })
  155. it('installs no dispatcher and touches no environment when the user exported none', async () => {
  156. const before = getGlobalDispatcher()
  157. process.env.HTTP_PROXY = 'http://untouched.example'
  158. const { dispose, reported } = await install(env({}))
  159. try {
  160. expect(getGlobalDispatcher()).toBe(before)
  161. expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
  162. expect(reported).toEqual([])
  163. expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
  164. } finally {
  165. await dispose()
  166. delete process.env.HTTP_PROXY
  167. }
  168. })
  169. it('keeps a scheme direct when the policy refused the proxy the user named for it', async () => {
  170. // What `HTTPS_PROXY=socks5://…` plus `HTTP_PROXY=http://p` resolves to: http proxied, https
  171. // direct. undici's own EnvHttpProxyAgent cannot express this — with no HTTPS proxy present it
  172. // reuses the HTTP one, tunnelling the scheme the diagnostic told the user stayed direct.
  173. const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
  174. try {
  175. // The direct path here fails on a DNS miss whose latency is the machine's resolver to decide;
  176. // the deadline bounds it. Either rejection proves the same thing — no CONNECT reached the
  177. // proxy — and a proxied hop would have answered in milliseconds instead.
  178. await expect(fetch('https://refused-scheme.invalid/', { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
  179. expect(proxied).toEqual([])
  180. // The same policy still tunnels http, so the empty expectation above is not vacuous.
  181. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
  182. expect(proxied).toEqual([`GET ${proxyTarget}`])
  183. } finally {
  184. await dispose()
  185. }
  186. })
  187. })
  188. describe('proxyRouteFor', () => {
  189. it('carries the dispatcher already routing, so a branch and its request agree', async () => {
  190. const { dispose } = await install(proxyAll())
  191. const route = proxyRouteFor(new URL(proxyTarget))
  192. expect(route).toMatchObject({ proxied: true, proxy: proxyUrl })
  193. if (!route.proxied) throw new Error('unreachable: asserted proxied above')
  194. // One transport, not a copy: a caller that branched on this route sends its request through the
  195. // very agent the branch described, so no second read can put the two on different routes.
  196. expect(route.dispatcher).toBe(getGlobalDispatcher())
  197. const undici = await import('undici')
  198. // Disposing the install while a request is in flight: the shared dispatcher is closed, not
  199. // destroyed, so the hop that already left finishes.
  200. const inFlight = undici.fetch(proxyTarget, { dispatcher: route.dispatcher })
  201. await dispose()
  202. await expect((await inFlight).text()).resolves.toBe('VIA-PROXY')
  203. expect(proxied).toEqual([`GET ${proxyTarget}`])
  204. })
  205. it('is direct for a bypassed URL, and direct with nothing installed', async () => {
  206. const { dispose } = await install(proxyAll('origin.test'))
  207. try {
  208. expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
  209. } finally {
  210. await dispose()
  211. }
  212. expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
  213. })
  214. it('is direct for a loopback URL under a policy that proxies everything', async () => {
  215. const { dispose } = await install(proxyAll())
  216. try {
  217. expect(proxyRouteFor(new URL(originUrl))).toEqual({ proxied: false })
  218. } finally {
  219. await dispose()
  220. }
  221. })
  222. })
  223. describe('proxyEnvironmentForChild', () => {
  224. it('is empty when no policy is installed', () => {
  225. expect(proxyEnvironmentForChild()).toEqual({})
  226. })
  227. it('is empty when the user exported none, so a child sees no flag it cannot use', async () => {
  228. const { dispose } = await install(env({}))
  229. try {
  230. expect(proxyEnvironmentForChild()).toEqual({})
  231. } finally {
  232. await dispose()
  233. }
  234. })
  235. it('hands a child the values the user exported, not this process\'s normalization', async () => {
  236. await withCleanProxyEnv(async () => {
  237. // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses.
  238. process.env.HTTP_PROXY = proxyUrl
  239. process.env.https_proxy = 'socks5://127.0.0.1:1080'
  240. const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080', NO_PROXY: 'example.com' }))
  241. try {
  242. const child = proxyEnvironmentForChild()
  243. // The published policy derived an HTTPS proxy for this process; the child must not see it.
  244. // Asserted over both casings rather than one: Windows folds the pair into a single variable,
  245. // so which spelling carries the value is the platform's to decide — that it is the user's
  246. // value and never the derived one is not.
  247. const https = [child.https_proxy, child.HTTPS_PROXY]
  248. expect(https).toContain('socks5://127.0.0.1:1080')
  249. expect(https).not.toContain(proxyUrl)
  250. expect(child.HTTP_PROXY).toBe(proxyUrl)
  251. // The bypass list is the resolved one: it only adds entries to what the user wrote, and
  252. // without the loopback ones the child sends its own localhost traffic to a proxy that
  253. // cannot route it.
  254. expect(child.no_proxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
  255. expect(child.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
  256. // The SOCKS value kept for `curl` is one Node would refuse at startup, so the flag that makes
  257. // Node read it is withheld and a child Node connects directly rather than failing to start.
  258. expect(child.NODE_USE_ENV_PROXY).toBeUndefined()
  259. } finally {
  260. await dispose()
  261. }
  262. })
  263. })
  264. it('fills a scheme the user named in neither casing, so a child Node is not left direct', async () => {
  265. await withCleanProxyEnv(async () => {
  266. // The user exported only ALL_PROXY. `NODE_USE_ENV_PROXY` never reads that name, so a child
  267. // Node would connect directly while this process proxies — the seam this fill closes.
  268. process.env.ALL_PROXY = proxyUrl
  269. const { dispose } = await install(env({ ALL_PROXY: proxyUrl }))
  270. try {
  271. const child = proxyEnvironmentForChild()
  272. expect(child.HTTP_PROXY).toBe(proxyUrl)
  273. expect(child.http_proxy).toBe(proxyUrl)
  274. expect(child.HTTPS_PROXY).toBe(proxyUrl)
  275. expect(child.https_proxy).toBe(proxyUrl)
  276. expect(child.NODE_USE_ENV_PROXY).toBe('1')
  277. } finally {
  278. await dispose()
  279. }
  280. })
  281. })
  282. it.each(['socks4://127.0.0.1:1080', 'ftp://p:1', 'not a url'])(
  283. 'withholds NODE_USE_ENV_PROXY when the child receives %s, so a child Node still starts',
  284. async (refused) => {
  285. await withCleanProxyEnv(async () => {
  286. process.env.HTTP_PROXY = proxyUrl
  287. process.env.HTTPS_PROXY = refused
  288. const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: refused }))
  289. try {
  290. const child = proxyEnvironmentForChild()
  291. // The value is still handed over — `curl` may read it — but Node, which parses these two
  292. // names before running anything under the flag, must not be told to.
  293. expect(child.HTTPS_PROXY).toBe(refused)
  294. expect(child.HTTP_PROXY).toBe(proxyUrl)
  295. expect(child).not.toHaveProperty('NODE_USE_ENV_PROXY')
  296. // Proved on a real child rather than inferred: the same environment with the flag present
  297. // exits before the program runs, on every Node this repository supports.
  298. const childEnv: Record<string, string> = { PATH: process.env.PATH ?? '' }
  299. for (const [name, value] of Object.entries(child)) if (value !== undefined) childEnv[name] = value
  300. const run = spawnSync(process.execPath, ['-e', 'process.stdout.write("started")'], { env: childEnv, encoding: 'utf8' })
  301. expect({ status: run.status, stdout: run.stdout }).toEqual({ status: 0, stdout: 'started' })
  302. } finally {
  303. await dispose()
  304. }
  305. })
  306. },
  307. )
  308. it('keeps the outermost install\'s record of what the user exported across a nested one', async () => {
  309. await withCleanProxyEnv(async () => {
  310. // The user exported one name, in one casing.
  311. process.env.HTTP_PROXY = proxyUrl
  312. // The launcher installs first; a second `installProxyFromEnvironment` layers another policy over it.
  313. const outer = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: 'example.com' }))
  314. try {
  315. const inner = await install(env({ HTTP_PROXY: nestedUrl, HTTPS_PROXY: nestedUrl }))
  316. try {
  317. const child = proxyEnvironmentForChild()
  318. // The user named no HTTPS proxy, so this scheme carries whichever policy is active. Reading
  319. // the outer install's published environment as the user's would pin it to the outer proxy
  320. // instead — the one discriminator that does not depend on how a platform cases names.
  321. expect(child.https_proxy).toBe(nestedUrl)
  322. expect(child.HTTPS_PROXY).toBe(nestedUrl)
  323. } finally {
  324. await inner.dispose()
  325. }
  326. // Unmounting the inner install must leave the outer one still able to describe that
  327. // environment; clearing the record instead makes this an empty object, so every later child
  328. // inherits the normalized values from `process.env` untouched.
  329. expect(proxyEnvironmentForChild().HTTP_PROXY).toBe(proxyUrl)
  330. expect(proxyEnvironmentForChild().https_proxy).toBe(proxyUrl)
  331. } finally {
  332. await outer.dispose()
  333. }
  334. })
  335. })
  336. })
  337. describe('installing over an existing installation', () => {
  338. it('stops proxying when the mounted policy proxies nothing', async () => {
  339. const outer = await install(proxyAll())
  340. try {
  341. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
  342. const off = await install(env({}))
  343. try {
  344. // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the
  345. // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one
  346. // reaches the real origin rather than the name only the proxy can resolve.
  347. await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
  348. expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
  349. } finally {
  350. await off.dispose()
  351. }
  352. // Disposing the direct policy restores the proxy the launcher installed.
  353. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
  354. expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
  355. } finally {
  356. await outer.dispose()
  357. }
  358. })
  359. })
  360. describe('the environment while a direct policy is layered over a proxied one', () => {
  361. it('hands a child the user\'s own values, and the outer normalization again afterwards', async () => {
  362. await withCleanProxyEnv(async () => {
  363. // The user exported one usable proxy and one this package refuses.
  364. process.env.HTTP_PROXY = proxyUrl
  365. process.env.https_proxy = 'socks5://127.0.0.1:1080'
  366. const outer = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }))
  367. try {
  368. // The outer install published its policy: the refused scheme is removed in both casings.
  369. expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
  370. const off = await install(env({}))
  371. try {
  372. // A spawned child copies `process.env`, and `proxyEnvironmentForChild()` adds nothing under a
  373. // direct policy — so what it copies has to be the user's own environment, not a normalization
  374. // no active policy stands behind: the SOCKS value they set for `curl` is theirs again.
  375. expect(process.env.HTTP_PROXY).toBe(proxyUrl)
  376. expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toContain('socks5://127.0.0.1:1080')
  377. expect(proxyEnvironmentForChild()).toEqual({})
  378. } finally {
  379. await off.dispose()
  380. }
  381. // Ending the window re-applies what the outer install published.
  382. expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined])
  383. expect(process.env.HTTP_PROXY).toBe(proxyUrl)
  384. } finally {
  385. await outer.dispose()
  386. }
  387. })
  388. })
  389. it('touches no environment when the install underneath proxied nothing', async () => {
  390. process.env.HTTP_PROXY = 'http://untouched.example'
  391. const outer = await install(env({}))
  392. const inner = await install(env({}))
  393. try {
  394. expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
  395. } finally {
  396. await inner.dispose()
  397. await outer.dispose()
  398. expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
  399. delete process.env.HTTP_PROXY
  400. }
  401. })
  402. })
  403. describe('the published environment', () => {
  404. it('restores every name from one snapshot taken before any write', async () => {
  405. process.env.http_proxy = 'http://before.example'
  406. process.env.HTTP_PROXY = 'http://before.example'
  407. const { dispose } = await install(proxyAll())
  408. expect(process.env.HTTP_PROXY).toBe(proxyUrl)
  409. await dispose()
  410. // Reading the uppercase spelling after writing the lowercase one must not restore the value
  411. // just written — the failure Windows's case-folded environment would produce.
  412. expect(process.env.http_proxy).toBe('http://before.example')
  413. expect(process.env.HTTP_PROXY).toBe('http://before.example')
  414. delete process.env.http_proxy
  415. delete process.env.HTTP_PROXY
  416. })
  417. })
  418. describe('clearedProxyEnv', () => {
  419. it('names every proxy variable for removal, so a replay reaches its own fixture server', () => {
  420. const cleared = clearedProxyEnv()
  421. expect(Object.keys(cleared).sort()).toEqual([...PROXY_ENV_NAMES].sort())
  422. expect(Object.values(cleared).every(value => value === undefined)).toBe(true)
  423. })
  424. })