Skip to content
high / h3 / / 5 min read

One Uppercase Letter Breaks Every Nuxt App

h3 powers every Nuxt app but only recognized 'chunked' in lowercase. Send 'ChunKed' instead and you get request smuggling.

How I Found It

h3 is the HTTP framework underneath Nuxt and Nitro. If you’ve deployed a Nuxt app, you’re running h3. If you’ve built an API with Nitro, you’re running h3. The npm package gets millions of downloads every week, and most of its users have never even heard of it because it just quietly handles every HTTP request their application receives.

I was reading through src/utils/body.ts, the file responsible for parsing request bodies. There’s a function called readRawBody, and right at the top, it has a guard: should I even bother reading the body? It checks for a Content-Length header, and it checks whether Transfer-Encoding contains chunked. If neither condition is true, it assumes there’s no body and returns immediately.

The Transfer-Encoding check is where things get interesting. The code splits the header on commas, trims whitespace, and calls .includes("chunked"). That looks reasonable at first glance. But .includes() is case-sensitive. And HTTP headers are not.

RFC 7230 is explicit about this: Transfer-Encoding values must be compared case-insensitively. chunked, Chunked, CHUNKED, ChunKed. They all mean the same thing. h3 only recognizes the first one.

One uppercase letter. That’s all it takes.

The Vulnerable Code

Here’s the guard:

if (
  !Number.parseInt(event.node.req.headers["content-length"] || "") &&
  !String(event.node.req.headers["transfer-encoding"] ?? "")
    .split(",")
    .map((e) => e.trim())
    .filter(Boolean)
    .includes("chunked")
) {
  return Promise.resolve(undefined);
}

Send Transfer-Encoding: ChunKed, and .includes("chunked") returns false. There’s no Content-Length either. So h3 concludes the request has no body, processes it immediately, and sends back a response without ever draining the incoming data from the socket.

Now put h3 behind a reverse proxy. The proxy receives the same request and parses Transfer-Encoding: ChunKed. If the proxy implements RFC 7230 correctly (case-insensitive comparison), it knows the body is chunked. It reads the chunked body, determines where the message ends, and forwards everything to h3.

But h3 disagrees. It doesn’t recognize ChunKed as chunked, so it treats the request as bodyless. It responds immediately without consuming the body data. The proxy thinks the first request is done (it read the full chunked body). h3 also thinks the first request is done (it never saw a body at all). But they disagree about how many bytes belong to that request.

That disagreement is the entire vulnerability. And it’s exactly what request smuggling is made of.

Turning a Case Bug Into Request Smuggling

Request smuggling is one of those vulnerability classes that sounds theoretical until you see it work. The core idea is simple: if two HTTP processors disagree about where one request ends and the next begins, an attacker can inject a second request that nobody asked for.

The exact topology determines how bad it gets. Take one of the most common setups: h3 behind a reverse proxy that correctly handles Transfer-Encoding (case-insensitively), forwarding over a persistent connection.

An attacker sends this:

POST /api/action
Host: target.example.com
Transfer-Encoding: ChunKed
5
Hello
0

The body is standard chunked encoding: 5 is the chunk size (in hex), Hello is the data, and 0 marks the final chunk. This is what a well-formed chunked request looks like.

Now watch what happens:

  1. The proxy receives the request, recognizes ChunKed as chunked (case-insensitive), reads the body through the terminating 0\r\n\r\n, and forwards everything to h3 over a shared backend connection.
  2. h3 checks Transfer-Encoding, sees ChunKed, and the case-sensitive .includes("chunked") fails. No Content-Length either. h3 treats the request as bodyless, processes it, and sends a response.
  3. The body bytes that h3 never consumed are still sitting in the connection buffer. On the shared backend connection, those leftover bytes get interpreted as the beginning of the next HTTP request. And the attacker controls exactly what those bytes say.

That’s the smuggle. The attacker crafts the “body” to look like a valid HTTP request. The proxy thinks it’s part of the first message. h3 thinks it’s a brand new request. Depending on what the attacker puts in those bytes, they could be reading another user’s response, bypassing authentication, or slipping past a WAF that already inspected and approved the outer request.

Proving the Desync

I needed to confirm that h3 actually ignores the body in this case. So I set up the same test against Express for comparison.

I sent both servers a Transfer-Encoding: ChunKed request, but deliberately left out the terminating 0\r\n\r\n chunk. A server that correctly recognizes the chunked encoding should hang, waiting for the body to complete.

Express hung. It recognized ChunKed as chunked transfer encoding and waited for the full body.

h3 responded instantly with a 200. It didn’t wait for anything, because it never realized there was a body to wait for.

Impact

What makes this dangerous isn’t the case sensitivity bug itself. It’s what happens when you combine it with the architecture of modern web deployments.

  • Invisible to WAFs. A WAF inspects the outer request and sees a valid chunked body. Everything looks clean. h3 ignores the body entirely. The leftover bytes become a smuggled request that the WAF never saw, never filtered, never logged.
  • Response poisoning across users. On a shared keep-alive connection, the smuggled request can desynchronize the entire response queue. User A gets the response that was meant for User B. Session tokens, API responses, personal data, all flowing to the wrong person.
  • Authentication bypass. If an auth proxy validates the incoming request, that validation only covers the outer request. The smuggled inner request reaches h3 directly, as if the auth layer didn’t exist.

The worst part: none of this leaves an obvious trace. The smuggled request looks like a normal request from the server’s perspective. It arrived on a legitimate connection. There’s no exploit payload in the logs, no anomalous headers, nothing to alert on. The attacker’s request just… appears, as if it came from another user.

Remediation

The fix in commit 618ccf4 replaces the entire split-and-compare chain with a single case-insensitive regex:

!/\bchunked\b/i.test(
  String(event.node.req.headers["transfer-encoding"] ?? ""),
)

The /i flag handles case insensitivity, and \b ensures it matches chunked as a whole word, not as a substring of something else. Cleaner than the original code, and correct. One line to fix a request smuggling vulnerability affecting every Nuxt application on the internet.

The fix shipped in h3 v1.15.5 the same day.

Timeline

  1. Vulnerability discovered and reported to h3 maintainers

  2. Fix committed and released in h3 v1.15.5

  3. CVE-2026-23527 and GHSA-mp2g-9vg9-f4cg published

--