Security News

Cybersecurity news aggregator

🔓
MEDIUM Vulnerabilities Reddit r/netsec

One Uppercase Letter Breaks Every Nuxt App

  • What: A case-sensitive check in the h3 HTTP framework could cause Nuxt apps to fail
  • Impact: Millions of Nuxt apps may be affected by this subtle bug
Read Full Article →

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: 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. 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. 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....

Share this article