proxy.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  4. import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
  5. import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http'
  6. import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http'
  7. import { isNonPublicIpLiteral, publicHttpNetwork } from '../src/network.ts'
  8. const limits: HttpFetchLimits = {
  9. maxResponseBytes: 5_000_000,
  10. maxBodyChars: 100_000,
  11. timeoutMs: 5_000,
  12. maxRedirects: 5,
  13. userAgent: 'test-agent/1.0',
  14. }
  15. /** Absolute-form targets the fake proxy saw; a populated entry proves the hop was tunnelled. */
  16. let proxied: string[]
  17. let proxy: Server
  18. let origin: Server
  19. let proxyUrl: string
  20. let originUrl: string
  21. /**
  22. * The target for every assertion about a tunnelled hop. Loopback cannot serve: no policy routes
  23. * this machine through a proxy. The host never resolves — the proxy answers the absolute-form
  24. * request — which is also what makes the skipped resolver observable.
  25. */
  26. const proxyTarget = 'http://origin.test/page'
  27. let disposeProxy: (() => Promise<void>) | undefined
  28. function listen(server: Server): Promise<AddressInfo> {
  29. return new Promise((resolve) => {
  30. server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) })
  31. })
  32. }
  33. function respond(_request: IncomingMessage, response: ServerResponse, body: string): void {
  34. response.writeHead(200, { 'content-type': 'text/plain' })
  35. response.end(body)
  36. }
  37. beforeEach(async () => {
  38. proxied = []
  39. proxy = createServer((request, response) => {
  40. proxied.push(request.url ?? '')
  41. respond(request, response, 'via-proxy')
  42. })
  43. origin = createServer((request, response) => { respond(request, response, '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)}/page`
  47. })
  48. afterEach(async () => {
  49. await disposeProxy?.()
  50. disposeProxy = undefined
  51. vi.restoreAllMocks()
  52. await Promise.all([
  53. new Promise<void>((resolve) => { proxy.close(() => { resolve() }) }),
  54. new Promise<void>((resolve) => { origin.close(() => { resolve() }) }),
  55. ])
  56. })
  57. /**
  58. * Install the policy of a user who exported one proxy for both schemes; the fixture disposes it
  59. * after every case.
  60. */
  61. async function installProxy(): Promise<() => Promise<void>> {
  62. const env = { get: (name: string) => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
  63. return await installProxyFromEnvironment(env, () => undefined)
  64. }
  65. describe('fetching through a proxy', () => {
  66. it('tunnels the request and never resolves a public address for it', async () => {
  67. const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
  68. disposeProxy = await installProxy()
  69. const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget })
  70. expect(result.body.content).toBe('via-proxy')
  71. expect(proxied).toEqual([proxyTarget])
  72. // Through a proxy the origin's DNS happens proxy-side, so the resolver that rejects non-public
  73. // destinations is not consulted at all.
  74. expect(resolve).not.toHaveBeenCalled()
  75. })
  76. it('keeps resolving and pinning a hop the policy does not proxy', async () => {
  77. const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
  78. .mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
  79. // No bypass entry needed: a resolved policy never routes loopback through a proxy, which is
  80. // exactly the case this asserts still resolves and pins.
  81. disposeProxy = await installProxy()
  82. const result = await new HttpFetchProvider(limits).fetch({ url: originUrl })
  83. expect(result.body.content).toBe('direct')
  84. expect(proxied).toEqual([])
  85. expect(resolve).toHaveBeenCalledOnce()
  86. })
  87. it('resolves and pins when no proxy is installed', async () => {
  88. const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
  89. .mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
  90. const result = await new HttpFetchProvider(limits).fetch({ url: originUrl })
  91. expect(result.body.content).toBe('direct')
  92. expect(resolve).toHaveBeenCalledOnce()
  93. })
  94. it.each(['10.0.0.5', '169.254.169.254', '127.0.0.2'])(
  95. 'refuses %s instead of letting the proxy reach it for us',
  96. async (host) => {
  97. const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
  98. disposeProxy = await installProxy()
  99. // The proxied path exists because a proxy resolves the origin; a literal needs no resolution,
  100. // so taking it would spend the address checks for nothing and hand a proxy on this machine
  101. // the private or loopback destination those checks exist to refuse. The hop therefore takes
  102. // the validated path instead, where the existing refusal already covers it.
  103. await expect(new HttpFetchProvider(limits).fetch({ url: `http://${host}:8080/` }))
  104. .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
  105. expect(proxied).toEqual([])
  106. expect(resolve).toHaveBeenCalledOnce()
  107. },
  108. )
  109. it('reads an IPv4-mapped literal as non-public without asking the network', () => {
  110. // Driven through the predicate rather than a fetch: an IPv6 literal sends `resolvePublicAddresses`
  111. // looking for a NAT64 prefix before it refuses anything, and that is a real DNS query. The three
  112. // IPv4 cases above already prove the branch end to end without one.
  113. expect(isNonPublicIpLiteral('[::ffff:7f00:1]')).toBe(true)
  114. expect(isNonPublicIpLiteral('[::1]')).toBe(true)
  115. expect(isNonPublicIpLiteral('[::ffff:808:808]')).toBe(false)
  116. expect(isNonPublicIpLiteral('example.com')).toBe(false)
  117. })
  118. it('still refuses a cross-origin redirect on the proxied path', async () => {
  119. proxy.removeAllListeners('request')
  120. proxy.on('request', (request, response) => {
  121. proxied.push(request.url ?? '')
  122. response.writeHead(302, { location: 'http://elsewhere.example/next' })
  123. response.end()
  124. })
  125. disposeProxy = await installProxy()
  126. await expect(new HttpFetchProvider(limits).fetch({ url: proxyTarget }))
  127. .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
  128. })
  129. it('still refuses a URL the transport policy rejects before any hop', async () => {
  130. disposeProxy = await installProxy()
  131. await expect(new HttpFetchProvider(limits).fetch({ url: 'ftp://example.com/x' }))
  132. .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
  133. expect(proxied).toEqual([])
  134. })
  135. })