Reports
MEDIA[DENEGACIÓN DE SERVICIO]#3456148

Node.js: la Fetch API aceptaba miles de capas de Content-Encoding y agotaba CPU y memoria del cliente

El fetch() de Node.js (undici) descomprimía tantas capas como listase la cabecera Content-Encoding. Un servidor malicioso podía encadenar miles de Brotli y bloquear al cliente durante minutos con solo 60 KB.

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

RFC 9110 permite que una respuesta HTTP indique varias codificaciones aplicadas una tras otra, por ejemplo Content-Encoding: gzip, br. La implementación de fetch() de Node.js, que vive en la dependencia undici (deps/undici/src/lib/web/fetch/index.js, función httpNetworkFetch), respetaba esa cadena sin poner ningún tope al número de eslabones.

El único freno era el tamaño máximo de cabeceras (maxHeaderSize), y su valor por defecto deja sitio de sobra para miles de entradas. Así, un servidor controlado por un atacante podía anunciar miles de pasos de compresión y obligar al cliente a deshacerlos todos, con un consumo de CPU y memoria muy desproporcionado respecto al tamaño de la respuesta.

El fallo se clasifica como CWE-770 (reserva de recursos sin límites ni control).

Pasos de reproducción

El investigador adjuntó dos scripts, server.js y client.js (no reproducidos en el reporte público):

  1. Arrancar un servidor que sirve 50 KB de datos comprimidos 5000 veces seguidas con Brotli:
$ BYTES=50000 LAYERS=5000 node server.js
Brotli stack server listening on http://localhost:3000/ (layers=5000, raw bytes=50000)
Raw content sample: Uint8Array(5) [ 4, 68, 220, 90, 43 ]
Served / (raw bytes=50000, encoded bytes=60892, layers=5000)
  1. Lanzar el cliente, que hace la petición y deja que Node.js descomprima la respuesta automáticamente. Midiéndolo con /usr/bin/time -v:
$ /usr/bin/time -v node client.js
Status: 200
Content-Encoding chain links: 5000
Decoded body sample: Uint8Array(5) [ 4, 68, 220, 90, 43 ]
        Command being timed: "node client.js"
        User time (seconds): 248.31
        System time (seconds): 229.12
        Percent of CPU this job got: 186%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 4:15.58
        ...
        Maximum resident set size (kbytes): 712332
        ...
        Voluntary context switches: 31856426
        Involuntary context switches: 997

Una respuesta de apenas 60 892 bytes mantuvo ocupada la máquina cliente más de cuatro minutos, con casi dos núcleos de CPU en uso y unos 700 MB de memoria residente. Según el investigador, aumentar el tamaño de los datos o el nivel de Brotli agravaría todavía más el consumo.

Impacto

Un servidor malicioso puede provocar un agotamiento de recursos (CPU y memoria) en cualquier proceso Node.js que le haga una petición con fetch(), a partir de una única respuesta de pocos kilobytes. Afecta sobre todo a aplicaciones que piden URLs que no controlan, como webhooks, previsualizadores de enlaces o integraciones con terceros.

Remediación

El reporte figura como resuelto; el texto público no detalla qué parche se aplicó finalmente. El investigador, que fue quien reportó y corrigió el problema equivalente en urllib3, recordó que urllib3 y curl (CVE-2022-32206, el antecedente en curl) fijaron en 5 el máximo de codificaciones por respuesta, y propuso el mismo límite para undici:

index a0dd75df7a5..2e5edef764d 100644
--- a/deps/undici/src/lib/web/fetch/index.js
+++ b/deps/undici/src/lib/web/fetch/index.js
@@ -2128,6 +2128,10 @@ async function httpNetworkFetch (
             // "All content-coding values are case-insensitive..."
             /** @type {string[]} */
             const codings = contentEncoding ? contentEncoding.toLowerCase().split(',') : []
+            if (codings.length > 5) {
+              reject(new Error(`too many content-encodings ${codings.length}, maximum allowed is 5.`))
+              return true
+            }
             for (let i = codings.length - 1; i >= 0; --i) {
               const coding = codings[i].trim()
               // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2

Referencias citadas en el reporte:

  • urllib3: https://github.com/advisories/GHSA-gm62-xv2j-4w53
  • curl: https://curl.se/docs/CVE-2022-32206.html

Qué aprender de este caso

  • En cualquier cliente HTTP que descomprima solo (fetch, axios, requests, librerías propias), busca dónde se parte la cabecera Content-Encoding por comas y comprueba si hay un tope al número de elementos antes del bucle que crea los descompresores.
  • Un límite de tamaño de cabeceras no basta como protección: lo que importa es cuánto trabajo genera cada elemento de la cabecera. Un valor de pocos kilobytes puede describir miles de operaciones costosas.
  • Este mismo fallo ya había aparecido en curl y en urllib3; cuando un cliente HTTP corrige un problema de este tipo, merece la pena probar el mismo ataque contra las demás implementaciones.
  • Si tu aplicación hace peticiones a URLs que no controlas, ponle un tiempo máximo y un límite de tamaño al cuerpo ya descomprimido, para que un servidor hostil no pueda bloquear el proceso.