vercel/next.js · #99477

fix(next/image): Pin DNS resolution when fetching external images

eps1lon · merged Sep 30, 20265 files · 1089 + / 189 −
packages/next/src/server/image-optimizer.ts362 + / 55 −
@@ -1,5 +1,11 @@ import { promises } from 'fs'-import type { IncomingMessage, ServerResponse } from 'http'+import type {+  IncomingMessage,+  RequestOptions as HttpRequestOptions,+  ServerResponse,+} from 'http'+import { Agent as HttpAgent, request as httpRequest } from 'http'+import { Agent as HttpsAgent, request as httpsRequest } from 'https' import { mediaType } from 'next/dist/compiled/@hapi/accept' import contentDisposition from 'next/dist/compiled/content-disposition' import { join } from 'path'@@ -26,8 +32,21 @@ import { parseUrl, parseReqUrl } from '../lib/url' import type { CacheControl } from './lib/cache-control' import { InvariantError } from '../shared/lib/invariant-error' import { lookup } from 'dns/promises'-import { isIP } from 'net'-import { ALL } from 'dns'+import { isIP, type LookupFunction } from 'net'+import { ADDRCONFIG, type LookupAddress } from 'dns'+import {+  pipeline,+  Transform,+  type Readable,+  type TransformCallback,+} from 'stream'+import {+  constants as zlibConstants,+  createBrotliDecompress,+  createGunzip,+  createInflate,+  createInflateRaw,+} from 'zlib' import {   imageOptimizerTransform,   type ImageUpstream,@@ -518,24 +537,286 @@ function isRedirect(statusCode: number) {   return [301, 302, 303, 307, 308].includes(statusCode) } +function upstreamTimedOut(href: string): ImageError {+  Log.error('upstream image response timed out for', href)+  return new ImageError(+    504,+    '"url" parameter is valid but upstream response timed out'+  )+}++const agentOptions = {+  keepAlive: true,+  timeout: 7_000,+}+type ImageRequestOptions = HttpRequestOptions & { imageLookupKey?: string }+type AgentGetName = (options?: HttpRequestOptions) => string++const getHttpAgentName = (+  HttpAgent.prototype as HttpAgent & { getName: AgentGetName }+).getName+const getHttpsAgentName = (+  HttpsAgent.prototype as HttpsAgent & { getName: AgentGetName }+).getName++class ImageHttpAgent extends HttpAgent {+  getName(options: ImageRequestOptions = {}): string {+    return `${getHttpAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`+  }+}++class ImageHttpsAgent extends HttpsAgent {+  getName(options: ImageRequestOptions = {}): string {+    return `${getHttpsAgentName.call(this, options)}:${options.imageLookupKey ?? ''}`+  }+}++let protectedHttpAgent: ImageHttpAgent | undefined+let protectedHttpsAgent: ImageHttpsAgent | undefined+let permissiveHttpAgent: ImageHttpAgent | undefined+let permissiveHttpsAgent: ImageHttpsAgent | undefined++function getImageAgent(+  dangerouslyAllowLocalIP: boolean,+  isHttps: boolean+): ImageHttpAgent | ImageHttpsAgent {+  if (isHttps) {+    if (dangerouslyAllowLocalIP) {+      return (permissiveHttpsAgent ??= new ImageHttpsAgent(agentOptions))+    }+    return (protectedHttpsAgent ??= new ImageHttpsAgent(agentOptions))+  }+  if (dangerouslyAllowLocalIP) {+    return (permissiveHttpAgent ??= new ImageHttpAgent(agentOptions))+  }+  return (protectedHttpAgent ??= new ImageHttpAgent(agentOptions))+}++const BASE_REQ_HEADERS = {+  accept: '*/*',+  'accept-language': '*',+  'sec-fetch-mode': 'cors',+  'user-agent': 'node',+}+const HTTP_FETCH_HEADERS = {+  ...BASE_REQ_HEADERS,+  'accept-encoding': 'gzip, deflate',+}+const HTTPS_FETCH_HEADERS = {+  ...BASE_REQ_HEADERS,+  'accept-encoding': 'br, gzip, deflate',+}++/**+ * Resolves `hostname` with the DNS options the socket itself would have used,+ * so the addresses checked below are the exact set the connection can reach.+ * Mirrors `lookupAndConnect()` in Node's `lib/net.js`.+ */+function lookupAsSocketWould(hostname: string): Promise<LookupAddress[]> {+  return lookup(hostname, {+    all: true,+    // Node applies ADDRCONFIG whenever no address family is requested,+    // on every platform except Windows.+    hints: process.platform === 'win32' ? 0 : ADDRCONFIG,+  })+}++/**+ * Pins the connection to `addresses` instead of letting it resolve `hostname`+ * a second time. Checking addresses up front only proves where the name+ * pointed at that moment; the socket runs its own lookup, so a record that+ * changes in between (DNS rebinding) would otherwise still reach a private IP.+ *+ * Every address in the list was checked, so all of them are handed back to+ * keep Node's happy eyeballs failover between IPv6 and IPv4 intact.+ */+export function createPinnedLookup(+  hostname: string,+  addresses: LookupAddress[]+): LookupFunction {+  return (lookupHostname, options, callback) => {+    if (lookupHostname !== hostname) {+      callback(+        new InvariantError(+          `Image lookup hostname "${lookupHostname}" does not match request hostname "${hostname}"`+        ),+        []+      )+      return+    }++    const family = typeof options.family === 'number' ? options.family : 0+    const matched = family+      ? addresses.filter((address) => address.family === family)+      : addresses++    if (matched.length === 0) {+      // Reporting this as an error rather than an empty list is not optional:+      // Node destructures the first address, so the resulting TypeError is+      // thrown inside the socket and escapes the request entirely.+      const err: NodeJS.ErrnoException = new Error(+        `No IPv${family} address available for ${hostname}`+      )+      err.code = 'ENODATA'+      callback(err, [])+      return+    }++    if (options.all) {+      callback(null, matched)+    } else {+      callback(null, matched[0].address, matched[0].family)+    }+  }+}++function requestUpstreamImage(+  url: URL,+  pinnedLookup: LookupFunction | undefined,+  imageLookupKey: string | undefined,+  dangerouslyAllowLocalIP: boolean,+  signal: AbortSignal+): Promise<IncomingMessage> {+  return new Promise((resolve, reject) => {+    const isHttps = url.protocol === 'https:'+    const request = isHttps ? httpsRequest : httpRequest+    const options: ImageRequestOptions = {+      agent: getImageAgent(dangerouslyAllowLocalIP, isHttps),+      headers: isHttps ? HTTPS_FETCH_HEADERS : HTTP_FETCH_HEADERS,+      imageLookupKey,+      lookup: pinnedLookup,+      signal,+    }+    const req = request(url, options, resolve)+    req.on('error', reject)+    req.end()+  })+}++class DeflateDecoder extends Transform {+  private decoder: Transform | undefined++  constructor() {+    super()+    this.decoder = undefined+  }++  _transform(+    chunk: Buffer,+    encoding: BufferEncoding,+    callback: TransformCallback+  ) {+    if (!this.decoder) {+      if (chunk.length === 0) {+        callback()+        return+      }+      const options = {+        flush: zlibConstants.Z_SYNC_FLUSH,+        finishFlush: zlibConstants.Z_SYNC_FLUSH,+      }+      this.decoder =+        (chunk[0] & 0x0f) === 0x08+          ? createInflate(options)+          : createInflateRaw(options)+      this.decoder.on('data', (data: Buffer) => this.push(data))+      this.decoder.on('end', () => this.push(null))+      this.decoder.on('error', (err) => this.destroy(err))+    }+    this.decoder.write(chunk, encoding, callback)+  }++  _final(callback: TransformCallback) {+    this.decoder?.end()+    this.decoder = undefined+    callback()+  }+}++// Matches the chain limit of native fetch (undici), so an upstream cannot+// force unbounded decoder allocations with the header alone.+const MAX_CONTENT_ENCODINGS = 5++function decodeResponseBody(res: IncomingMessage, href: string): Readable {+  const contentEncoding = res.headers['content-encoding']+  if (!contentEncoding) {+    return res+  }++  const codings = contentEncoding.toLowerCase().split(',')+  if (codings.length > MAX_CONTENT_ENCODINGS) {+    Log.error(+      'upstream image response had too many content encodings for',+      href+    )+    throw new ImageError(+      400,+      '"url" parameter is valid but upstream response is invalid'+    )+  }+  const decoders: Transform[] = []+  for (let i = codings.length - 1; i >= 0; i--) {+    switch (codings[i].trim()) {+      case 'x-gzip':+      case 'gzip':+        decoders.push(+          createGunzip({+            flush: zlibConstants.Z_SYNC_FLUSH,+            finishFlush: zlibConstants.Z_SYNC_FLUSH,+          })+        )+        break+      case 'deflate':+        decoders.push(new DeflateDecoder())+        break+      case 'br':+        decoders.push(+          createBrotliDecompress({+            flush: zlibConstants.BROTLI_OPERATION_FLUSH,+            finishFlush: zlibConstants.BROTLI_OPERATION_FLUSH,+          })+        )+        break+      default:+        return res+    }+  }++  pipeline([res, ...decoders], () => {})+  return decoders[decoders.length - 1]+}+ export async function fetchExternalImage(   href: string,   dangerouslyAllowLocalIP: boolean,   maximumResponseBody: number,   count = 3 ): Promise<ImageUpstream> {+  const url = new URL(href)+  // `URL.hostname` keeps the brackets around an IPv6 literal, the socket does not+  const hostname = url.hostname.replace(/^\[|\]$/g, '')+  let pinnedLookup: LookupFunction | undefined+  let imageLookupKey: string | undefined+   if (!dangerouslyAllowLocalIP) {-    const { hostname } = new URL(href)-    let ips = [hostname]-    if (!isIP(hostname)) {-      const records = await lookup(hostname, {-        family: 0,-        all: true,-        hints: ALL,-      }).catch((_) => [{ address: hostname }])-      ips = records.map((record) => record.address)-    }-    const privateIps = ips.filter((ip) => isPrivateIp(ip))+    const literalFamily = isIP(hostname)+    let addresses: LookupAddress[]++    if (literalFamily > 0) {+      // A literal address is connected to as-is, without any DNS resolution+      addresses = [{ address: hostname, family: literalFamily }]+    } else {+      addresses = await lookupAsSocketWould(hostname)+      pinnedLookup = createPinnedLookup(hostname, addresses)+    }+    imageLookupKey = addresses+      .map((address) => `${address.family}:${address.address}`)+      .join(',')++    const privateIps = addresses+      .map((record) => record.address)+      .filter((ip) => isPrivateIp(ip))+     if (privateIps.length > 0) {       Log.error(         'upstream image',@@ -547,29 +828,44 @@ export async function fetchExternalImage(       throw new ImageError(400, '"url" parameter is not allowed')     }   }-  const res = await fetch(href, {-    signal: AbortSignal.timeout(7_000),-    redirect: 'manual',-  }).catch((err) => err as Error) -  if (res instanceof Error) {-    const err = res as Error-    if (err.name === 'TimeoutError') {-      Log.error('upstream image response timed out for', href)-      throw new ImageError(-        504,-        '"url" parameter is valid but upstream response timed out'-      )+  // The signal is owned here rather than by `requestUpstreamImage()` because it+  // has to stay readable while the body streams. Node reports an abort during+  // the body as a plain `ECONNRESET` on the response, so the signal is the only+  // way to tell a timeout apart from the upstream resetting the connection.+  const signal = AbortSignal.timeout(agentOptions.timeout)+  let res: IncomingMessage+  try {+    res = await requestUpstreamImage(+      url,+      pinnedLookup,+      imageLookupKey,+      dangerouslyAllowLocalIP,+      signal+    )+  } catch (err) {+    if (signal.aborted) {+      throw upstreamTimedOut(href)     }     throw err   } -  const locationHeader = res.headers.get('Location')+  const statusCode = res.statusCode+  if (statusCode === undefined) {+    res.destroy()+    throw new InvariantError('Expected statusCode on upstream image response')+  }+  const locationHeader = res.headers.location   if (-    isRedirect(res.status) &&+    isRedirect(statusCode) &&     locationHeader &&     URL.canParse(locationHeader, href)   ) {+    // Discard the body rather than draining it. A redirect body is never used,+    // and draining one would download unlimited bytes from the upstream because+    // `maximumResponseBody` is only enforced on the final response.+    res.destroy()+     if (count === 0) {       Log.error('upstream image response had too many redirects', href)       throw new ImageError(@@ -586,46 +882,57 @@ export async function fetchExternalImage(     )   } -  if (!res.ok) {-    Log.error('upstream image response failed for', href, res.status)+  if (statusCode < 200 || statusCode > 299) {+    res.destroy()+    Log.error('upstream image response failed for', href, statusCode)     throw new ImageError(-      res.status,+      statusCode,       '"url" parameter is valid but upstream response is invalid'     )   } -  if (!res.body) {+  const chunks: Buffer[] = []+  let totalSize = 0++  try {+    const body = decodeResponseBody(res, href)+    // Throwing out of this loop destroys the response, so an oversized or+    // timed out download stops instead of running to completion.+    for await (const chunk of body as AsyncIterable<Buffer>) {+      totalSize += chunk.byteLength+      if (totalSize > maximumResponseBody) {+        Log.error(+          'upstream image response exceeded maximum size for',+          href,+          totalSize+        )+        throw new ImageError(+          413,+          '"url" parameter is valid but upstream response is invalid'+        )+      }+      chunks.push(chunk)+    }+  } catch (err) {+    res.destroy()+    if (signal.aborted) {+      throw upstreamTimedOut(href)+    }+    throw err+  }++  if (totalSize === 0) {     Log.error('upstream image response is empty for', href)     throw new ImageError(       400,       '"url" parameter is valid but upstream response is invalid'     )   } -  const chunks: Buffer[] = []-  let totalSize = 0--  for await (const c of res.body) {-    const chunk = Buffer.from(c)-    totalSize += chunk.byteLength-    if (totalSize > maximumResponseBody) {-      Log.error(-        'upstream image response exceeded maximum size for',-        href,-        totalSize-      )-      throw new ImageError(-        413,-        '"url" parameter is valid but upstream response is invalid'-      )-    }-    chunks.push(chunk)-  }-   const buffer = Buffer.concat(chunks)-  const contentType = res.headers.get('Content-Type')-  const cacheControl = res.headers.get('Cache-Control')-  const etag = extractEtag(res.headers.get('ETag'), buffer)+  const contentType = res.headers['content-type']+  const cacheControl = res.headers['cache-control']+  const etag = extractEtag(res.headers.etag, buffer)   return { buffer, contentType, cacheControl, etag } } 
test/e2e/testmode/next.config.js3 + / 0 −
@@ -1,5 +1,8 @@ /** @type {import('next').NextConfig} */ module.exports = {+  images: {+    remotePatterns: [new URL('https://example.com/**')],+  },   experimental: {     testProxy: true,   },
test/e2e/testmode/testmode.test.ts24 + / 0 −
@@ -1,6 +1,11 @@ import { nextTestSetup } from 'e2e-utils' import { createProxyServer } from 'next/experimental/testmode/proxy' +const PNG_1X1 = Buffer.from(+  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',+  'base64'+)+ describe('testmode', () => {   const { next, skipped } = nextTestSetup({     files: __dirname,@@ -17,6 +22,14 @@ describe('testmode', () => {   beforeEach(async () => {     proxyServer = await createProxyServer({       onFetch: async (testData, request) => {+        if (+          request.method === 'GET' &&+          request.url === 'https://example.com/image.png'+        ) {+          return new Response(PNG_1X1, {+            headers: { 'content-type': 'image/png' },+          })+        }         if (           request.method === 'GET' &&           [@@ -139,4 +152,15 @@ describe('testmode', () => {       expect(text).toEqual('test1')     })   })++  describe('image optimizer', () => {+    it('should intercept external image requests', async () => {+      const response = await fetchForTest(+        '/_next/image?url=https%3A%2F%2Fexample.com%2Fimage.png&w=64&q=75'+      )++      expect(response.status).toBe(200)+      expect(response.headers.get('content-type')).toMatch(/^image\//)+    })+  }) })
test/unit/image-optimizer/create-pinned-lookup.test.tsadded65 + / 0 −
@@ -0,0 +1,65 @@+/* eslint-env jest */+import { ADDRCONFIG, type LookupAddress, type LookupOptions } from 'dns'+import { createPinnedLookup } from 'next/dist/server/image-optimizer'++const HOSTNAME = 'example.com'+const IPV4: LookupAddress = { address: '93.184.216.34', family: 4 }+const IPV6: LookupAddress = {+  address: '2606:2800:220:1:248:1893:25c8:1946',+  family: 6,+}++function callLookup(+  options: LookupOptions,+  { hostname = HOSTNAME, addresses = [IPV4, IPV6] } = {}+) {+  const calls: Array<{+    err: NodeJS.ErrnoException | null+    result: string | LookupAddress[]+    family?: number+  }> = []++  createPinnedLookup(HOSTNAME, addresses)(+    hostname,+    options,+    (err, result, family) => calls.push({ err, result, family })+  )++  expect(calls).toHaveLength(1)+  return calls[0]+}++describe('createPinnedLookup', () => {+  it('should hand back every pinned address for the options a socket uses', () => {+    const { err, result } = callLookup({ all: true, hints: ADDRCONFIG })+    expect(err).toBeNull()+    expect(result).toEqual([IPV4, IPV6])+  })++  it('should report ENODATA rather than an empty list when the family matches nothing', () => {+    // An empty list is not recoverable: node destructures the first address, so+    // the resulting TypeError is thrown inside the socket and escapes the request+    const { err, result } = callLookup(+      { all: true, family: 6 },+      { addresses: [IPV4] }+    )+    expect(err?.code).toBe('ENODATA')+    expect(result).toEqual([])+  })++  it('should report ENODATA when the family matches nothing and all is unset', () => {+    const { err } = callLookup({ family: 6 }, { addresses: [IPV4] })+    expect(err?.code).toBe('ENODATA')+  })++  it('should return a single address of the requested family', () => {+    const { err, result, family } = callLookup({ family: 6 })+    expect(err).toBeNull()+    expect({ result, family }).toEqual({ result: IPV6.address, family: 6 })+  })++  it('should refuse to resolve a hostname other than the pinned one', () => {+    const { err } = callLookup({ all: true }, { hostname: 'attacker.example' })+    expect(err?.message).toContain('does not match request hostname')+  })+})
test/unit/image-optimizer/fetch-external-image.test.ts635 + / 134 −
@@ -1,73 +1,371 @@ /* eslint-env jest */+import {+  createServer,+  type IncomingMessage,+  type Server,+  type ServerResponse,+} from 'http'+import type { AddressInfo } from 'net'+import { readFile } from 'fs/promises'+import { join } from 'path'+import { brotliCompressSync, deflateRawSync, deflateSync, gzipSync } from 'zlib' import {   fetchExternalImage,   ImageError, } from 'next/dist/server/image-optimizer'+import { detectContentType } from 'next/dist/server/image-optimizer/detect-content-type'++jest.mock('dns/promises', () => {+  const actual = jest.requireActual('dns/promises')+  return { ...actual, lookup: jest.fn(actual.lookup) }+})++jest.mock('next/dist/server/is-private-ip', () => {+  const actual = jest.requireActual('next/dist/server/is-private-ip')+  return { ...actual, isPrivateIp: jest.fn(actual.isPrivateIp) }+})++const { lookup: lookupMock } =+  jest.requireMock<typeof import('dns/promises')>('dns/promises')+const { isPrivateIp: isPrivateIpMock } = jest.requireMock<+  typeof import('next/dist/server/is-private-ip')+>('next/dist/server/is-private-ip')++const MAXIMUM_RESPONSE_BODY = 50_000_000+const actualLookup =+  jest.requireActual<typeof import('dns/promises')>('dns/promises').lookup+const actualIsPrivateIp = jest.requireActual<+  typeof import('next/dist/server/is-private-ip')+>('next/dist/server/is-private-ip').isPrivateIp++let server: Server+let handler: (req: IncomingMessage, res: ServerResponse) => void+let requests: string[]++function port() {+  return (server.address() as AddressInfo).port+}++function origin() {+  return `http://127.0.0.1:${port()}`+}++beforeAll(async () => {+  server = createServer((req, res) => {+    requests.push(req.url ?? '')+    // The client destroys the socket when a response is too large, so writes+    // from these handlers are expected to fail+    res.on('error', () => {})+    handler(req, res)+  })+  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))+})++afterAll(async () => {+  server.closeAllConnections()+  await new Promise<void>((resolve) => server.close(() => resolve()))+})++beforeEach(() => {+  requests = []+  handler = (_req, res) => {+    res.writeHead(200, { 'content-type': 'image/jpeg' })+    res.end(Buffer.from([0xff, 0xd8, 0xff]))+  }+})++afterEach(() => {+  jest.mocked(lookupMock).mockReset().mockImplementation(actualLookup)+  jest.mocked(isPrivateIpMock).mockReset().mockImplementation(actualIsPrivateIp)+})  describe('fetchExternalImage', () => {   describe('private IP / SSRF guard', () => {     it('should reject a literal private IP hostname with a generic error message', async () => {-      const fetchMock = jest.fn()-      global.fetch = fetchMock+      const error = await fetchExternalImage(+        `${origin()}/private.jpg`,+        false,+        MAXIMUM_RESPONSE_BODY+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(400)+      expect((error as ImageError).message).toBe(+        '"url" parameter is not allowed'+      )+      expect(requests).toHaveLength(0)+    })++    it('should reject a hostname that resolves to a private IP', async () => {+      jest+        .mocked(lookupMock)+        .mockResolvedValueOnce([{ address: '10.0.0.1', family: 4 }] as any)        const error = await fetchExternalImage(-        'http://192.168.0.1/private.jpg',+        'http://internal.invalid/private.jpg',         false,-        50_000_000+        MAXIMUM_RESPONSE_BODY       ).catch((e) => e)        expect(error).toBeInstanceOf(ImageError)       expect((error as ImageError).statusCode).toBe(400)       expect((error as ImageError).message).toBe(         '"url" parameter is not allowed'       )-      expect(fetchMock).not.toHaveBeenCalled()+      expect(requests).toHaveLength(0)     })      it('should allow a literal private IP when dangerouslyAllowLocalIP is true', async () => {-      global.fetch = jest.fn().mockResolvedValue({-        ok: true,-        status: 200,-        body: new ReadableStream({-          start(controller) {-            controller.enqueue(new Uint8Array([1, 2, 3]))-            controller.close()-          },-        }),-        headers: {-          get: jest.fn((header: string) => {-            if (header === 'Content-Type') return 'image/jpeg'-            return null-          }),-        },-      })+      const result = await fetchExternalImage(+        `${origin()}/private.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.buffer).toEqual(Buffer.from([0xff, 0xd8, 0xff]))+      expect(requests).toEqual(['/private.jpg'])+    })++    it('should skip the DNS lookup for a literal IP', async () => {+      await fetchExternalImage(+        `${origin()}/literal.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(lookupMock).not.toHaveBeenCalled()+    })+  })++  describe('DNS pinning', () => {+    it('should connect to the address that was checked instead of resolving again', async () => {+      // `.invalid` never resolves, so the request can only succeed if the+      // socket uses the address that `isPrivateIp()` already approved+      jest+        .mocked(lookupMock)+        .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+      jest.mocked(isPrivateIpMock).mockReturnValueOnce(false)        const result = await fetchExternalImage(-        'http://192.168.0.1/private.jpg',+        `http://pinned.invalid:${port()}/pinned.jpg`,+        false,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.buffer).toEqual(Buffer.from([0xff, 0xd8, 0xff]))+      expect(requests).toEqual(['/pinned.jpg'])+      expect(lookupMock).toHaveBeenCalledTimes(1)+      expect(lookupMock).toHaveBeenCalledWith(+        'pinned.invalid',+        expect.objectContaining({ all: true })+      )+    })++    it('should send the original hostname in the Host header', async () => {+      jest+        .mocked(lookupMock)+        .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+      jest.mocked(isPrivateIpMock).mockReturnValueOnce(false)++      let host: string | undefined+      handler = (req, res) => {+        host = req.headers.host+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }++      await fetchExternalImage(+        `http://pinned.invalid:${port()}/host.jpg`,+        false,+        MAXIMUM_RESPONSE_BODY+      )++      expect(host).toBe(`pinned.invalid:${port()}`)+    })++    it('should not reuse a permissive socket for a protected request', async () => {+      const sockets = new Set()+      handler = (req, res) => {+        sockets.add(req.socket)+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }+      jest.mocked(isPrivateIpMock).mockReturnValueOnce(false)++      await fetchExternalImage(+        `${origin()}/permissive.jpg`,         true,-        50_000_000+        MAXIMUM_RESPONSE_BODY+      )+      await fetchExternalImage(+        `${origin()}/protected.jpg`,+        false,+        MAXIMUM_RESPONSE_BODY       ) -      expect(result.buffer).toBeInstanceOf(Buffer)-      expect(global.fetch).toHaveBeenCalled()+      expect(sockets.size).toBe(2)+    })++    it('should pool permissive sockets separately', async () => {+      const sockets = new Set()+      handler = (req, res) => {+        sockets.add(req.socket)+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }++      const href = `${origin()}/permissive.jpg`+      await fetchExternalImage(href, true, MAXIMUM_RESPONSE_BODY)+      await fetchExternalImage(href, true, MAXIMUM_RESPONSE_BODY)++      expect(sockets.size).toBe(1)+    })++    it('should pool protected sockets', async () => {+      const sockets = new Set()+      handler = (req, res) => {+        sockets.add(req.socket)+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }+      jest+        .mocked(lookupMock)+        .mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as any)+      jest.mocked(isPrivateIpMock).mockReturnValue(false)++      const href = `http://protected.invalid:${port()}/protected.jpg`+      await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+      await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)++      expect(sockets.size).toBe(1)+      expect(lookupMock).toHaveBeenCalledTimes(2)+    })++    it('should reject a private DNS answer before reusing a protected socket', async () => {+      jest+        .mocked(lookupMock)+        .mockResolvedValue([{ address: '127.0.0.1', family: 4 }] as any)+      jest+        .mocked(isPrivateIpMock)+        .mockReturnValueOnce(false)+        .mockReturnValueOnce(true)++      const href = `http://revalidate.invalid:${port()}/protected.jpg`+      await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+      const error = await fetchExternalImage(+        href,+        false,+        MAXIMUM_RESPONSE_BODY+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(400)+      expect(requests).toEqual(['/protected.jpg'])+    })++    it('should not reuse a socket for a different validated address', async () => {+      const sockets = new Set()+      handler = (req, res) => {+        sockets.add(req.socket)+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }+      jest+        .mocked(lookupMock)+        .mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }] as any)+        .mockResolvedValueOnce([+          { address: '127.0.0.1', family: 4 },+          { address: '127.0.0.2', family: 4 },+        ] as any)+      jest.mocked(isPrivateIpMock).mockReturnValue(false)++      const href = `http://rebound.invalid:${port()}/protected.jpg`+      await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)+      await fetchExternalImage(href, false, MAXIMUM_RESPONSE_BODY)++      expect(sockets.size).toBe(2)+      expect(lookupMock).toHaveBeenCalledTimes(2)     })   }) -  describe('response size limit', () => {-    it('should throw error when response has no body', async () => {-      global.fetch = jest.fn().mockResolvedValue({-        ok: true,-        status: 200,-        body: null,-        headers: {-          get: jest.fn(() => null),-        },+  describe('response handling', () => {+    it('should send the same default headers as fetch', async () => {+      let headers: IncomingMessage['headers'] | undefined+      handler = (req, res) => {+        headers = req.headers+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([0xff, 0xd8, 0xff]))+      }++      await fetchExternalImage(+        `${origin()}/headers.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(headers).toEqual({+        accept: '*/*',+        'accept-encoding': 'gzip, deflate',+        'accept-language': '*',+        connection: 'keep-alive',+        host: `127.0.0.1:${port()}`,+        'sec-fetch-mode': 'cors',+        'user-agent': 'node',       })+    })++    it('should return the content type, cache control and etag', async () => {+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-type': 'image/png',+          'cache-control': 'public, max-age=600',+          etag: '"upstream-etag"',+        })+        res.end(Buffer.from([1, 2, 3]))+      }++      const result = await fetchExternalImage(+        `${origin()}/headers.png`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.contentType).toBe('image/png')+      expect(result.cacheControl).toBe('public, max-age=600')+      expect(result.etag).toBe(+        Buffer.from('"upstream-etag"').toString('base64url')+      )+    })++    it('should throw error when the upstream response is not ok', async () => {+      handler = (_req, res) => {+        res.writeHead(404)+        res.end()+      }        const error = await fetchExternalImage(-        'http://example.com/no-body.jpg',-        false,-        50_000_000+        `${origin()}/missing.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(404)+      expect((error as ImageError).message).toBe(+        '"url" parameter is valid but upstream response is invalid'+      )+    })++    it('should throw error when response has no body', async () => {+      handler = (_req, res) => {+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end()+      }++      const error = await fetchExternalImage(+        `${origin()}/no-body.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY       ).catch((e) => e)        expect(error).toBeInstanceOf(ImageError)@@ -77,40 +375,290 @@ describe('fetchExternalImage', () => {       )     }) +    it.each([+      ['gzip', 'gzip', gzipSync],+      ['Brotli', 'br', brotliCompressSync],+      ['zlib-wrapped deflate', 'deflate', deflateSync],+      ['raw deflate', 'deflate', deflateRawSync],+    ])(+      'should decompress a %s-encoded SVG response',+      async (_name, encoding, zip) => {+        const svg = await readFile(join(__dirname, 'images/test.svg'))+        handler = (_req, res) => {+          res.writeHead(200, {+            'content-encoding': encoding,+            'content-type': 'image/svg+xml',+          })+          res.end(zip(svg))+        }++        const result = await fetchExternalImage(+          `${origin()}/compressed.svg`,+          true,+          MAXIMUM_RESPONSE_BODY+        )++        expect(result.buffer).toEqual(svg)+        expect(result.contentType).toBe('image/svg+xml')+        expect(await detectContentType(result.buffer)).toBe('image/svg+xml')+      }+    )++    it('should decompress chained content encodings', async () => {+      const svg = await readFile(join(__dirname, 'images/test.svg'))+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-encoding': 'gzip, br',+          'content-type': 'image/svg+xml',+        })+        res.end(brotliCompressSync(gzipSync(svg)))+      }++      const result = await fetchExternalImage(+        `${origin()}/compressed.svg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.buffer).toEqual(svg)+    })++    it('should decompress five chained content encodings', async () => {+      const svg = await readFile(join(__dirname, 'images/test.svg'))+      let body = svg+      for (let i = 0; i < 5; i++) {+        body = gzipSync(body)+      }+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-encoding': 'gzip, gzip, gzip, gzip, gzip',+          'content-type': 'image/svg+xml',+        })+        res.end(body)+      }++      const result = await fetchExternalImage(+        `${origin()}/compressed.svg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.buffer).toEqual(svg)+    })++    it('should reject more than five chained content encodings', async () => {+      const svg = await readFile(join(__dirname, 'images/test.svg'))+      let body = svg+      for (let i = 0; i < 6; i++) {+        body = gzipSync(body)+      }+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-encoding': 'gzip, gzip, gzip, gzip, gzip, gzip',+          'content-type': 'image/svg+xml',+        })+        res.end(body)+      }++      const error = await fetchExternalImage(+        `${origin()}/compressed.svg`,+        true,+        MAXIMUM_RESPONSE_BODY+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(400)+      expect((error as ImageError).message).toBe(+        '"url" parameter is valid but upstream response is invalid'+      )+    })++    it('should reject an over-long content-encoding header without a body', async () => {+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-encoding': Array(1000).fill('gzip').join(', '),+          'content-type': 'image/svg+xml',+        })+        res.end()+      }++      const error = await fetchExternalImage(+        `${origin()}/compressed.svg`,+        true,+        MAXIMUM_RESPONSE_BODY+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(400)+      expect((error as ImageError).message).toBe(+        '"url" parameter is valid but upstream response is invalid'+      )+    })++    it('should enforce maximumResponseBody on decompressed bytes', async () => {+      const maximumResponseBody = 2_000+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-encoding': 'gzip',+          'content-type': 'image/svg+xml',+        })+        res.end(gzipSync(Buffer.alloc(maximumResponseBody + 1)))+      }++      const error = await fetchExternalImage(+        `${origin()}/compressed.svg`,+        true,+        maximumResponseBody+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(413)+    })++    it('should follow a redirect', async () => {+      handler = (req, res) => {+        if (req.url === '/redirect.jpg') {+          res.writeHead(302, { location: '/final.jpg' })+          return res.end()+        }+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.from([4, 5, 6]))+      }++      const result = await fetchExternalImage(+        `${origin()}/redirect.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )++      expect(result.buffer).toEqual(Buffer.from([4, 5, 6]))+      expect(requests).toEqual(['/redirect.jpg', '/final.jpg'])+    })++    it('should throw error when there are too many redirects', async () => {+      handler = (_req, res) => {+        res.writeHead(302, { location: '/loop.jpg' })+        res.end()+      }++      const error = await fetchExternalImage(+        `${origin()}/loop.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY,+        0+      ).catch((e) => e)++      expect(error).toBeInstanceOf(ImageError)+      expect((error as ImageError).statusCode).toBe(508)+      expect((error as ImageError).message).toBe(+        '"url" parameter is valid but upstream response is invalid'+      )+    })++    it('should not download the body of a redirect response', async () => {+      // `maximumResponseBody` is only enforced on the final response, so+      // draining a redirect body would let the upstream send unlimited bytes+      const redirectBodySize = 64 * 1024 * 1024+      const chunk = Buffer.alloc(1024 * 1024)+      let bytesSent = 0+      let bodyFullyWritten = false+      let redirectClosed: Promise<void> | undefined++      handler = (req, res) => {+        if (req.url !== '/redirect-body.jpg') {+          res.writeHead(200, { 'content-type': 'image/jpeg' })+          return res.end(Buffer.from([4, 5, 6]))+        }+        res.writeHead(302, { location: '/final.jpg' })+        redirectClosed = new Promise((resolve) => res.on('close', resolve))+        const write = () => {+          if (res.destroyed || res.writableEnded) return+          if (bytesSent >= redirectBodySize) {+            bodyFullyWritten = true+            return res.end()+          }+          bytesSent += chunk.byteLength+          if (res.write(chunk)) setImmediate(write)+          else res.once('drain', write)+        }+        write()+      }++      const result = await fetchExternalImage(+        `${origin()}/redirect-body.jpg`,+        true,+        MAXIMUM_RESPONSE_BODY+      )+      await redirectClosed++      expect(result.buffer).toEqual(Buffer.from([4, 5, 6]))+      expect(requests).toEqual(['/redirect-body.jpg', '/final.jpg'])+      // Discarding the body stops at whatever the socket already buffered,+      // whereas draining it would transfer every byte the upstream offered+      expect(bodyFullyWritten).toBe(false)+      expect(bytesSent).toBeLessThan(redirectBodySize / 2)+    })+  })++  describe('upstream timeout', () => {+    // The 7s upstream timeout is not configurable, so both phases are covered+    // by one test to avoid waiting for it twice+    it('should map a timeout to a 504 before the headers and during the body', async () => {+      handler = (req, res) => {+        if (req.url === '/stalled-body.jpg') {+          // Headers arrive, then the body stalls until the timeout fires+          res.writeHead(200, { 'content-type': 'image/jpeg' })+          res.write(Buffer.from([0xff, 0xd8]))+        }+        // `/stalled-headers.jpg` is never answered at all+      }++      const summarize = (error: unknown) =>+        error instanceof ImageError+          ? { statusCode: error.statusCode, message: error.message }+          : { statusCode: 'not an ImageError', message: String(error) }++      const [beforeHeaders, duringBody] = await Promise.all([+        fetchExternalImage(+          `${origin()}/stalled-headers.jpg`,+          true,+          MAXIMUM_RESPONSE_BODY+        ).catch((e) => e),+        fetchExternalImage(+          `${origin()}/stalled-body.jpg`,+          true,+          MAXIMUM_RESPONSE_BODY+        ).catch((e) => e),+      ])++      const timedOut = {+        statusCode: 504,+        message: '"url" parameter is valid but upstream response timed out',+      }+      expect({+        beforeHeaders: summarize(beforeHeaders),+        duringBody: summarize(duringBody),+      }).toEqual({ beforeHeaders: timedOut, duringBody: timedOut })+    }, 20_000)+  })++  describe('response size limit', () => {     it('should throw error when exceeding maximumResponseBody config on later chunk', async () => {       const maximumResponseBody = 2_000 // 2KB custom limit       const chunkSize = 1_000 // 1KB chunks       const numChunks = 3 // 3KB total, exceeds custom 2KB limit -      global.fetch = jest.fn().mockImplementation(() => {-        let chunksRead = 0-        const mockReadableStream = new ReadableStream({-          async pull(controller) {-            if (chunksRead < numChunks) {-              controller.enqueue(new Uint8Array(chunkSize))-              chunksRead++-            } else {-              controller.close()-            }-          },-        })--        return Promise.resolve({-          ok: true,-          status: 200,-          body: mockReadableStream,-          headers: {-            get: jest.fn((header: string) => {-              if (header === 'Content-Type') return 'image/jpeg'-              return null-            }),-          },-        })-      })+      handler = (_req, res) => {+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        for (let i = 0; i < numChunks; i++) {+          res.write(Buffer.alloc(chunkSize))+        }+        res.end()+      }        const error = await fetchExternalImage(-        'http://example.com/custom-limit.jpg',-        false,+        `${origin()}/custom-limit.jpg`,+        true,         maximumResponseBody       ).catch((e) => e) @@ -124,30 +672,14 @@ describe('fetchExternalImage', () => {     it('should throw error when exceeding maximumResponseBody config on first chunk', async () => {       const maximumResponseBody = 2_000 // 2KB custom limit -      global.fetch = jest.fn().mockImplementation(() => {-        const mockReadableStream = new ReadableStream({-          async pull(controller) {-            controller.enqueue(new Uint8Array(maximumResponseBody + 1))-            controller.close()-          },-        })--        return Promise.resolve({-          ok: true,-          status: 200,-          body: mockReadableStream,-          headers: {-            get: jest.fn((header: string) => {-              if (header === 'Content-Type') return 'image/jpeg'-              return null-            }),-          },-        })-      })+      handler = (_req, res) => {+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.alloc(maximumResponseBody + 1))+      }        const error = await fetchExternalImage(-        'http://example.com/custom-limit.jpg',-        false,+        `${origin()}/custom-limit.jpg`,+        true,         maximumResponseBody       ).catch((e) => e) @@ -161,30 +693,14 @@ describe('fetchExternalImage', () => {     it('should succeed when exactly matching maximumResponseBody config on first chunk', async () => {       const maximumResponseBody = 3_000 // 3KB custom limit -      global.fetch = jest.fn().mockImplementation(() => {-        const mockReadableStream = new ReadableStream({-          async pull(controller) {-            controller.enqueue(new Uint8Array(maximumResponseBody))-            controller.close()-          },-        })--        return Promise.resolve({-          ok: true,-          status: 200,-          body: mockReadableStream,-          headers: {-            get: jest.fn((header: string) => {-              if (header === 'Content-Type') return 'image/jpeg'-              return null-            }),-          },-        })-      })+      handler = (_req, res) => {+        res.writeHead(200, { 'content-type': 'image/jpeg' })+        res.end(Buffer.alloc(maximumResponseBody))+      }        const result = await fetchExternalImage(-        'http://example.com/custom-limit.jpg',-        false,+        `${origin()}/custom-limit.jpg`,+        true,         maximumResponseBody       ) @@ -197,35 +713,20 @@ describe('fetchExternalImage', () => {       const chunkSize = 1_000 // 1KB chunks       const numChunks = 3 // 3KB total -      global.fetch = jest.fn().mockImplementation(() => {-        let chunksRead = 0-        const mockReadableStream = new ReadableStream({-          async pull(controller) {-            if (chunksRead < numChunks) {-              controller.enqueue(new Uint8Array(chunkSize))-              chunksRead++-            } else {-              controller.close()-            }-          },+      handler = (_req, res) => {+        res.writeHead(200, {+          'content-type': 'image/jpeg',+          'content-length': String(maximumResponseBody),         })--        return Promise.resolve({-          ok: true,-          status: 200,-          body: mockReadableStream,-          headers: {-            get: jest.fn((header: string) => {-              if (header === 'Content-Type') return 'image/jpeg'-              return null-            }),-          },-        })-      })+        for (let i = 0; i < numChunks; i++) {+          res.write(Buffer.alloc(chunkSize))+        }+        res.end()+      }        const result = await fetchExternalImage(-        'http://example.com/custom-limit.jpg',-        false,+        `${origin()}/custom-limit.jpg`,+        true,         maximumResponseBody       )