current commit
261014.12 · fix(fetch): abort excessive content encoding responses
'use strict'

const { once } = require('node:events')
const { createServer } = require('node:http')
const { createServer: createSecureServer } = require('node:https')
const { createServer: createNetServer } = require('node:net')
const { test, before, after, describe } = require('node:test')
const pem = require('@metcoder95/https-pem')
const { fetch, Client } = require('../..')

describe('content-encoding handling', () => {
  const gzipDeflateText = Buffer.from('H4sIAAAAAAAAA6uY89nj7MmT1wM5zuuf8gxkYZCfx5IFACQ8u/wVAAAA', 'base64')
  const zstdText = Buffer.from('KLUv/QBYaQAASGVsbG8sIFdvcmxkIQ==', 'base64')

  let server
  before(async () => {
    server = createServer({
      noDelay: true
    }, (req, res) => {
      res.socket.setNoDelay(true)
      if (
        req.headers['accept-encoding'] === 'deflate, gzip' ||
        req.headers['accept-encoding'] === 'DeFlAtE, GzIp'
      ) {
        res.writeHead(200,
          {
            'Content-Encoding': 'deflate, gzip',
            'Content-Type': 'text/plain'
          }
        )
        res.flushHeaders()
        res.end(gzipDeflateText)
      } else if (req.headers['accept-encoding'] === 'zstd') {
        res.writeHead(200,
          {
            'Content-Encoding': 'zstd',
            'Content-Type': 'text/plain'
          }
        )
        res.flushHeaders()
        res.end(zstdText)
      } else {
        res.writeHead(200,
          {
            'Content-Type': 'text/plain'
          }
        )
        res.flushHeaders()
        res.end('Hello, World!')
      }
    })
    await once(server.listen(0), 'listening')
  })

  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  test('content-encoding header', async (t) => {
    const response = await fetch(`http://localhost:${server.address().port}`, {
      keepalive: false,
      headers: { 'accept-encoding': 'deflate, gzip' }
    })

    t.assert.strictEqual(response.headers.get('content-encoding'), 'deflate, gzip')
    t.assert.strictEqual(response.headers.get('content-type'), 'text/plain')
    t.assert.strictEqual(await response.text(), 'Hello, World!')
  })

  test('content-encoding header is case-iNsENsITIve', async (t) => {
    const response = await fetch(`http://localhost:${server.address().port}`, {
      keepalive: false,
      headers: { 'accept-encoding': 'DeFlAtE, GzIp' }
    })

    t.assert.strictEqual(response.headers.get('content-encoding'), 'deflate, gzip')
    t.assert.strictEqual(response.headers.get('content-type'), 'text/plain')
    t.assert.strictEqual(await response.text(), 'Hello, World!')
  })

  test('should decompress zstandard response', async (t) => {
    const response = await fetch(`http://localhost:${server.address().port}`, {
      keepalive: false,
      headers: { 'accept-encoding': 'zstd' }
    })

    t.assert.strictEqual(response.headers.get('content-encoding'), 'zstd')
    t.assert.strictEqual(response.headers.get('content-type'), 'text/plain')
    t.assert.strictEqual(await response.text(), 'Hello, World!')
  })
})

describe('content-encoding chain limit', () => {
  // CVE fix: Limit the number of content-encodings to prevent resource exhaustion
  // Similar to urllib3 (GHSA-gm62-xv2j-4w53) and curl (CVE-2022-32206)
  const MAX_CONTENT_ENCODINGS = 5

  let server
  before(async () => {
    server = createServer({
      noDelay: true
    }, (req, res) => {
      res.socket.setNoDelay(true)
      const encodingCount = parseInt(req.headers['x-encoding-count'] || '1', 10)
      const encodings = Array(encodingCount).fill('identity').join(', ')

      res.writeHead(200, {
        'Content-Encoding': encodings,
        'Content-Type': 'text/plain'
      })
      res.flushHeaders()
      res.end('test')
    })
    await once(server.listen(0, '127.0.0.1'), 'listening')
  })

  after(() => {
    server.closeAllConnections?.()
    server.close()
  })

  test(`should allow exactly ${MAX_CONTENT_ENCODINGS} content-encodings`, async (t) => {
    const client = new Client(`http://127.0.0.1:${server.address().port}`)
    t.after(() => client.close())

    const response = await fetch(`http://127.0.0.1:${server.address().port}`, {
      dispatcher: client,
      keepalive: false,
      headers: { 'x-encoding-count': String(MAX_CONTENT_ENCODINGS) }
    })

    t.assert.strictEqual(response.status, 200)
    // identity encoding is a no-op, so the body should be passed through
    t.assert.strictEqual(await response.text(), 'test')
  })

  test(`should reject more than ${MAX_CONTENT_ENCODINGS} content-encodings`, async (t) => {
    const client = new Client(`http://127.0.0.1:${server.address().port}`)
    t.after(() => client.close())

    await t.assert.rejects(
      fetch(`http://127.0.0.1:${server.address().port}`, {
        dispatcher: client,
        keepalive: false,
        headers: { 'x-encoding-count': String(MAX_CONTENT_ENCODINGS + 1) }
      }),
      (err) => {
        t.assert.ok(err.cause?.message.includes('content-encoding'))
        return true
      }
    )
  })

  test('should reject excessive content-encoding chains', async (t) => {
    const client = new Client(`http://127.0.0.1:${server.address().port}`)
    t.after(() => client.close())

    await t.assert.rejects(
      fetch(`http://127.0.0.1:${server.address().port}`, {
        dispatcher: client,
        keepalive: false,
        headers: { 'x-encoding-count': '100' }
      }),
      (err) => {
        t.assert.ok(err.cause?.message.includes('content-encoding'))
        return true
      }
    )
  })

  test('should handle an early close after rejecting excessive content-encodings', async (t) => {
    const response = Buffer.from(
      'HTTP/1.1 200 OK\r\n' +
      `Content-Encoding: ${Array(MAX_CONTENT_ENCODINGS + 1).fill('gzip').join(', ')}\r\n` +
      'Content-Length: 1\r\n' +
      'Connection: close\r\n\r\n'
    )
    let resolveSocketClosed
    const socketClosed = new Promise((resolve) => {
      resolveSocketClosed = resolve
    })
    const earlyCloseServer = createNetServer((socket) => {
      socket.once('close', resolveSocketClosed)
      socket.once('data', () => socket.end(response))
    })
    t.after(() => earlyCloseServer.close())

    await once(earlyCloseServer.listen(0, '127.0.0.1'), 'listening')

    const client = new Client(`http://127.0.0.1:${earlyCloseServer.address().port}`)
    t.after(() => client.close())

    await t.assert.rejects(
      fetch(`http://127.0.0.1:${earlyCloseServer.address().port}`, {
        dispatcher: client
      }),
      (err) => {
        t.assert.ok(err.cause?.message.includes('content-encoding'))
        return true
      }
    )
    await socketClosed
  })
})

describe('default accept-encoding request header', () => {
  test('http request accept-encoding', async (t) => {
    t.plan(1)

    const server = createServer({ noDelay: true }, (req, res) => {
      t.assert.deepStrictEqual(req.headers['accept-encoding'], 'gzip, deflate')

      res.end()
    }).listen(0, '127.0.0.1')

    t.after(() => {
      server.closeAllConnections()
      server.close()
    })

    await once(server, 'listening')

    const response = await fetch(`http://127.0.0.1:${server.address().port}`)
    await response.arrayBuffer()
  })

  test('https request accept-encoding', async (t) => {
    t.plan(1)

    const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }), (req, res) => {
      t.assert.deepStrictEqual(req.headers['accept-encoding'], 'br, gzip, deflate, zstd')

      res.end()
    }).listen(0, '127.0.0.1')

    t.after(() => {
      server.closeAllConnections()
      server.close()
    })
    await once(server, 'listening')

    const url = `https://127.0.0.1:${server.address().port}`

    const client = new Client(url, { connect: { rejectUnauthorized: false } })
    t.after(() => client.close())

    const response = await fetch(url, { dispatcher: client })
    await response.arrayBuffer()
  })
})