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):
- 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)
- 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-Encodingpor 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.