policy.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { describe, expect, it } from 'vitest'
  2. import type { EnvLookup } from '../src/policy.ts'
  3. import {
  4. bypassesProxy,
  5. isLoopbackHost,
  6. proxyForUrl,
  7. resolveProxyPolicy,
  8. DIRECT_POLICY,
  9. } from '../src/policy.ts'
  10. /** Windows folds environment names, so a case distinction cannot be expressed there at all. */
  11. const FOLDS_ENV_CASE = process.platform === 'win32'
  12. const PROXY = 'http://127.0.0.1:7897'
  13. const OTHER = 'http://127.0.0.1:8080'
  14. /**
  15. * The environment resolution reads. Windows folds names case-insensitively, and the launcher's own
  16. * snapshot does the same, so this stand-in folds too — otherwise a case-distinction case would
  17. * assert POSIX behaviour on a platform that cannot have it.
  18. */
  19. function env(values: Record<string, string>): EnvLookup {
  20. const folded = FOLDS_ENV_CASE
  21. ? Object.fromEntries(Object.entries(values).map(([name, value]) => [name.toUpperCase(), value]))
  22. : values
  23. return { get: (name) => {
  24. const value = folded[FOLDS_ENV_CASE ? name.toUpperCase() : name]
  25. return value === undefined ? undefined : { value }
  26. } }
  27. }
  28. describe('loopback routing', () => {
  29. const proxied = { httpProxy: PROXY, httpsProxy: PROXY, noProxy: '', source: 'env' } as const
  30. // The published bypass list carries four literal entries for the consumers that read an
  31. // environment. Matching only those left the rest of `127.0.0.0/8` — including the resolver stub
  32. // at `127.0.0.53` — routed through a proxy that could then reach it on the caller's behalf.
  33. it.each([
  34. '127.0.0.1', '127.0.0.2', '127.0.0.53', '127.255.255.254',
  35. 'localhost', 'app.localhost', '[::1]', '[::ffff:127.0.0.1]', '0.0.0.0',
  36. ])('never routes %s through a proxy', (host) => {
  37. expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBeUndefined()
  38. })
  39. it.each(['128.0.0.1', '10.0.0.5', '[::ffff:10.0.0.1]', 'notlocalhost', 'example.com'])(
  40. 'still routes %s, which is not this machine',
  41. (host) => {
  42. expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBe(PROXY)
  43. },
  44. )
  45. it('rejects an out-of-range octet rather than reading it as loopback', () => {
  46. expect(isLoopbackHost('127.999.1.1')).toBe(false)
  47. expect(isLoopbackHost('1270.0.0.1')).toBe(false)
  48. })
  49. it('reads an IPv4-mapped address in either spelling', () => {
  50. // A URL normalizes the dotted tail into hex groups, but a caller reading a bypass list or a
  51. // configuration value has the dotted form in hand, and both name the same address.
  52. expect(isLoopbackHost('::ffff:127.0.0.1')).toBe(true)
  53. expect(isLoopbackHost('::ffff:7f00:1')).toBe(true)
  54. expect(isLoopbackHost('::ffff:10.0.0.1')).toBe(false)
  55. expect(isLoopbackHost('::ffff:a00:1')).toBe(false)
  56. })
  57. })
  58. describe('resolveProxyPolicy', () => {
  59. it('resolves nothing when the environment carries no proxy', () => {
  60. const { policy, diagnostics } = resolveProxyPolicy(env({}))
  61. expect(policy).toEqual(DIRECT_POLICY)
  62. expect(diagnostics).toEqual([])
  63. })
  64. it('reads both schemes and merges loopback into the bypass list', () => {
  65. const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, HTTPS_PROXY: OTHER, NO_PROXY: 'example.com' }))
  66. expect(policy.httpProxy).toBe(PROXY)
  67. expect(policy.httpsProxy).toBe(OTHER)
  68. expect(policy.noProxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
  69. expect(policy.source).toBe('env')
  70. })
  71. it('prefers the lowercase name, matching undici', () => {
  72. const { policy } = resolveProxyPolicy(env({ http_proxy: PROXY, HTTP_PROXY: OTHER }))
  73. // Windows has no such preference to express: the launch snapshot folds names, so the two
  74. // spellings are one variable there and the later entry is simply the value. Asserted rather
  75. // than skipped, so a change to that folding fails here instead of passing unnoticed.
  76. expect(policy.httpProxy).toBe(FOLDS_ENV_CASE ? OTHER : PROXY)
  77. })
  78. it('treats a blank lowercase value as unset instead of letting it shadow the uppercase one', () => {
  79. const { policy } = resolveProxyPolicy(env({ http_proxy: ' ', HTTP_PROXY: PROXY }))
  80. expect(policy.httpProxy).toBe(PROXY)
  81. })
  82. it('leaves http direct when only the https variable is set', () => {
  83. // The reverse of the HTTP-only case: the fallback runs one way, so naming only HTTPS proxies
  84. // that scheme alone and every `http:` request stays direct.
  85. const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: PROXY }))
  86. expect(policy.httpProxy).toBeUndefined()
  87. expect(policy.httpsProxy).toBe(PROXY)
  88. expect(proxyForUrl(policy, new URL('http://example.com/'))).toBeUndefined()
  89. expect(proxyForUrl(policy, new URL('https://example.com/'))).toBe(PROXY)
  90. })
  91. it('backs both schemes with ALL_PROXY, which neither Node nor undici reads', () => {
  92. const { policy } = resolveProxyPolicy(env({ ALL_PROXY: PROXY }))
  93. expect(policy.httpProxy).toBe(PROXY)
  94. expect(policy.httpsProxy).toBe(PROXY)
  95. })
  96. it('lets a scheme-specific value outrank ALL_PROXY', () => {
  97. const { policy } = resolveProxyPolicy(env({ ALL_PROXY: PROXY, HTTPS_PROXY: OTHER }))
  98. expect(policy.httpProxy).toBe(PROXY)
  99. expect(policy.httpsProxy).toBe(OTHER)
  100. })
  101. it('falls HTTPS back to the HTTP proxy last, so the dispatcher and proxyForUrl agree', () => {
  102. const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY }))
  103. expect(policy.httpsProxy).toBe(PROXY)
  104. })
  105. it('keeps a bypass list of * unchanged', () => {
  106. const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, NO_PROXY: '*' }))
  107. expect(policy.noProxy).toBe('*')
  108. })
  109. it('does not repeat a loopback entry the user already listed', () => {
  110. const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, NO_PROXY: 'localhost, 127.0.0.1' }))
  111. expect(policy.noProxy).toBe('localhost,127.0.0.1,::1,[::1]')
  112. })
  113. it('keeps a scheme direct when its own value was refused, rather than falling back', () => {
  114. const { policy, diagnostics } = resolveProxyPolicy(env({ HTTPS_PROXY: 'socks5://127.0.0.1:1080', HTTP_PROXY: PROXY }))
  115. expect(policy.httpProxy).toBe(PROXY)
  116. // The diagnostic says HTTPS connects directly; the route must agree rather than borrowing the
  117. // HTTP proxy the user never named for HTTPS.
  118. expect(policy.httpsProxy).toBeUndefined()
  119. expect(proxyForUrl(policy, new URL('https://example.com/'))).toBeUndefined()
  120. expect(diagnostics[0]?.message).toMatch(/connecting directly for that scheme/)
  121. })
  122. it('keeps a scheme direct when its own value was malformed, past ALL_PROXY too', () => {
  123. const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: 'not a url', ALL_PROXY: PROXY }))
  124. expect(policy.httpProxy).toBe(PROXY)
  125. expect(policy.httpsProxy).toBeUndefined()
  126. })
  127. it('reports a SOCKS proxy instead of silently ignoring it', () => {
  128. const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'socks5://127.0.0.1:7890' }))
  129. expect(policy).toEqual(DIRECT_POLICY)
  130. expect(diagnostics).toHaveLength(1)
  131. expect(diagnostics[0]?.kind).toBe('socks')
  132. expect(diagnostics[0]?.message).toMatch(/SOCKS proxy, which is not supported/)
  133. })
  134. it('reports an unparseable proxy URL', () => {
  135. const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'not a url' }))
  136. expect(policy).toEqual(DIRECT_POLICY)
  137. expect(diagnostics[0]?.kind).toBe('invalid')
  138. // The origin names the spelling resolution asked for, which on a folded environment is the
  139. // lowercase one it tries first — the same variable the user set, reported in the other case.
  140. expect(diagnostics[0]?.origin).toBe(FOLDS_ENV_CASE ? 'http_proxy' : 'HTTP_PROXY')
  141. })
  142. it('reports a proxy URL whose scheme is neither http(s) nor SOCKS', () => {
  143. const { diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'ftp://proxy.example' }))
  144. expect(diagnostics[0]?.message).toMatch(/unsupported ftp:\/\/ scheme/)
  145. })
  146. })
  147. describe('bypassesProxy', () => {
  148. const cases: [string, string, boolean][] = [
  149. ['example.com', 'http://example.com/a', true],
  150. ['example.com', 'http://sub.example.com/a', true],
  151. ['example.com', 'http://notexample.com/a', false],
  152. ['.example.com', 'http://sub.example.com/a', true],
  153. ['*.example.com', 'http://sub.example.com/a', true],
  154. ['example.com', 'http://example.com./a', true],
  155. ['*', 'http://anything.example/a', true],
  156. ['example.com:8080', 'http://example.com:8080/a', true],
  157. ['example.com:8080', 'http://example.com:9090/a', false],
  158. ['example.com:80', 'http://example.com/a', true],
  159. ['example.com:443', 'https://example.com/a', true],
  160. ['EXAMPLE.com', 'http://example.COM/a', true],
  161. ['localhost', 'http://localhost:3000/a', true],
  162. ['127.0.0.1', 'http://127.0.0.1:7777/a', true],
  163. ]
  164. it.each(cases)('bypass %j against %j is %s', (noProxy, url, expected) => {
  165. expect(bypassesProxy(noProxy, new URL(url))).toBe(expected)
  166. })
  167. it('bypasses a bare IPv6 loopback, which undici reads as host ":" port "1"', () => {
  168. expect(bypassesProxy('::1', new URL('http://[::1]:3000/a'))).toBe(true)
  169. })
  170. it('bypasses the bracketed IPv6 form as well', () => {
  171. expect(bypassesProxy('[::1]', new URL('http://[::1]/a'))).toBe(true)
  172. })
  173. it('honors a port on a bracketed IPv6 entry', () => {
  174. expect(bypassesProxy('[::1]:3000', new URL('http://[::1]:3000/a'))).toBe(true)
  175. expect(bypassesProxy('[::1]:3000', new URL('http://[::1]:4000/a'))).toBe(false)
  176. })
  177. it('does not match CIDR notation, so an OS bypass list must be rewritten as suffixes', () => {
  178. expect(bypassesProxy('10.0.0.0/8', new URL('http://10.1.2.3/a'))).toBe(false)
  179. })
  180. it('skips blank and bracket-only entries', () => {
  181. expect(bypassesProxy(' , , [ , .', new URL('http://example.com/a'))).toBe(false)
  182. })
  183. })
  184. describe('proxyForUrl', () => {
  185. const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, HTTPS_PROXY: OTHER, NO_PROXY: 'direct.example' }))
  186. it('routes http through the http proxy', () => {
  187. expect(proxyForUrl(policy, new URL('http://example.com/'))).toBe(PROXY)
  188. })
  189. it('routes https through the https proxy', () => {
  190. expect(proxyForUrl(policy, new URL('https://example.com/'))).toBe(OTHER)
  191. })
  192. it('returns nothing for a bypassed host', () => {
  193. expect(proxyForUrl(policy, new URL('https://direct.example/'))).toBeUndefined()
  194. })
  195. it('returns nothing for loopback, which is always bypassed', () => {
  196. expect(proxyForUrl(policy, new URL('http://127.0.0.1:9000/'))).toBeUndefined()
  197. })
  198. it('returns nothing for a non-http scheme', () => {
  199. expect(proxyForUrl(policy, new URL('ws://example.com/'))).toBeUndefined()
  200. })
  201. it('returns nothing under a direct policy', () => {
  202. expect(proxyForUrl(DIRECT_POLICY, new URL('https://example.com/'))).toBeUndefined()
  203. })
  204. })