2026

May 19, 2026

The Flight Protocol Made Your DoS My Problem

On May 6, 2026, React and Next.js patched twelve vulnerabilities. One of them, CVE-2026-23870, is a single HTTP request that pins your Node process. The bug isn't the bug. The bug is that the framework boundary was a network boundary all along.

S
Sascha Becker
Author

14 min read

The Flight Protocol Made Your DoS My Problem

The Flight Protocol Made Your DoS My Problem

On May 6, 2026, between one batched advisory and a slightly embarrassed pnpm up, the React and Next.js teams shipped patches for twelve vulnerabilities.1 Most of them are routine: a clutch of middleware bypasses, an SSRF in WebSocket upgrade handling, an XSS that needed a CSP nonce to land. Normal patch-Tuesday fare for a framework at this scale.

One of them is not routine. CVE-2026-23870 is a high-severity denial-of-service in React's Flight protocol deserialization path. A single crafted HTTP request, the kind of thing a bored attacker generates with curl, can pin a Node worker on excessive CPU until the runtime gives up. No authentication required. No exotic preconditions. Just a request shaped wrong on purpose, and a server that trusted the shape.

The bug itself will get patched and forgotten by next sprint. What's worth dwelling on is what the bug reveals about how we've been writing Server Components for two years.

What Flight Actually Is

A short primer first. React Server Components are components that run on the server only and never ship JavaScript to the browser. The server renders them, serializes the resulting tree into bytes, and streams those bytes to the client where React reconstructs the UI. That serialization step is Flight.

If you've shipped a React Server Component, you've shipped Flight. You probably haven't read the protocol.

Flight is the wire format React uses to ship server-rendered component trees, Server Function calls and their results, and the references between them, from the server to the client (or from one server to another). It is not React. It is not HTML. It is its own newline-delimited streaming protocol. Each line is a chunk:23

0:"Hello from the server"
1:{"name":"Alice","age":30}
2:["$","div",null,{"children":"$0"}]

Three chunks: a string, a plain object, and a React element whose children reference chunk 0 via "$0". The $ shows up twice and means two different things. The bare "$" inside the array is React's marker for "this is an element"; the "$0" is a pointer to another chunk, so a value used in ten places only has to be serialized once.

Each chunk has an ID in hex, an optional one-letter tag (I for module references, E for errors, Q for Maps, W for Sets, o for typed arrays), and a JSON payload. The format dedupes by construction and represents the kind of cyclic, reference-rich structures that plain JSON cannot.

This is a protocol. It is a protocol your server speaks to anyone who points an HTTP client at it. Server Actions sit directly on top of it. A Server Action is an async function you mark with "use server" and call straight from a client component, often as the action prop of a <form>. The function looks like a function. Underneath, the framework wires up an HTTP route whose request body is your function's arguments, encoded as Flight chunks and parsed by the same deserializer the server uses for inbound RSC payloads.

You exposed a wire format. The framework chose how strict the parser would be.

The Bug, In One Paragraph

CVE-2026-23870 affects react-server-dom-parcel, react-server-dom-webpack, and react-server-dom-turbopack on every released 19.x line up to 19.0.5, 19.1.6, and 19.2.5.4 The deserializer in those packages accepts inbound Flight payloads without adequately enforcing structural or type constraints. Translation: you can hand it a payload whose chunk graph is malformed, whose tags do not match the value shape, or whose references nest in ways the parser was not sized for, and the parser will burn CPU trying to make sense of it. Excessive CPU. Enough to make the worker stop answering anyone else.

The fix is the parser-hygiene work the original deserializer should have shipped with: structural validation, depth and size caps, type-tag enforcement. Patched in 19.0.6, 19.1.7, and 19.2.6. Cloudflare's WAF (web application firewall) added generic rules in front of the same class of malformed-payload attacks.5 The Next.js release covers App Router 13.x through 16.x.

Am I Vulnerable?

Two checks. Take thirty seconds.

Check the installed version. Run one of these in your app root, depending on your package manager:

bash
pnpm why react-server-dom-webpack
npm ls react-server-dom-webpack
yarn why react-server-dom-webpack

If the resolved version is below 19.0.6, 19.1.7, or 19.2.6 (whichever 19.x line you are on), you ship the vulnerable deserializer.

Run the audit. Your package manager already knows about CVE-2026-23870, which has been in the GitHub Advisory Database since May 6:

bash
pnpm audit
npm audit

The audit will name the CVE directly. If you depend on Next.js, it will flag the bundled react-server-dom-* even when you do not import it yourself.

The fix. Most apps just need to upgrade Next.js, which pulls the patched react-server-dom-* in as a transitive dependency:

next # latest on your major; 13.x, 14.x, 15.x, and 16.x all have patched lines
react-server-dom-webpack@19.2.6 # only if you depend on it directly; -parcel / -turbopack for those bundlers

If you want to see how many endpoints this patch is closing for you, grep for the directive:

bash
rg '"use server"' --type ts -l

That count is your Server Action footprint. Even with zero matches, you are still affected: the same deserializer runs on every cached RSC payload and on the inbound RSC route Next.js mounts for client-side navigations. App Router apps are in scope regardless of whether you write Server Actions yourself.

The Framework Boundary Was Always a Network Boundary

Here's the part that should outlast the patch.

React Server Components were sold to the audience as a render-time optimization. You write components on the server, the framework figures out which bits ship to the client as interactive JavaScript and which stay as plain HTML, and you stop paying the cost of bundling and re-running JavaScript for parts of the page that were never going to be interactive. The framing was: "It's like SSR, but better." That framing trained a generation of frontend engineers to treat the server / client boundary as an internal implementation detail, the same way they treat the boundary between a parent component and a child.

The runtime never agreed. At the runtime layer, the boundary is a serialization boundary. Every Server Action your codebase exposes is a deserialization endpoint that accepts attacker-controlled bytes. Every cached RSC payload is a record that gets fed back through the same deserializer. The 'use server' directive is not an annotation. It is an inbound HTTP handler with Flight-shaped expectations.

When the parser is permissive about shape, every component author who wrote a Server Action wrote, by accident, an unauthenticated RPC endpoint with no input validation. That description should make any backend engineer flinch. Frontend engineers writing the same code did not flinch, because the framing did not ask them to.

This is not the first time React's wire layer was the bug. Last year's CVE-2025-55182 was an RCE through Flight payload deserialization, later christened "React2Shell" in writeups.6 Same protocol. Same trust assumption. Different consequence. The DoS is the gentler cousin in the same family.

Why We Missed It

The mental-model failure is easy to reconstruct.

You write a Server Action that accepts formData. It looks like a function. You type-annotate the argument because TypeScript yells if you do not. You return { ok: true }. From inside the codebase, this is a function. From outside the codebase, it is an HTTP endpoint whose request body is parsed by a streaming deserializer for a protocol most of the team has never read.

Three things compounded.

The protocol was undocumented for most of RSC's early life. The React team treated Flight as an implementation detail. Community writeups27 reverse-engineered the format from the source. If your team's understanding of Flight comes from a blog post written by someone who read the source, your understanding is probably correct, but it is not a threat model.

The error surface of a Server Action is invisible at the call site. You cannot grep for "endpoints that accept untrusted input" in a Next.js codebase, because the endpoints are inferred from 'use server' directives. A reviewer cannot count what a reviewer cannot see.

The framework's defaults treated parsing as performance, not as security. Streaming, lazy, reference-rich deserialization is a real performance win. It also means the parser optimistically follows references before it knows the payload is well formed. A parser optimised for the happy path is a parser that allocates badly on the adversarial path.

None of these is a smoking gun on its own. Together they made the bug feel inevitable in retrospect.

What To Actually Do

Patch first. The version bumps above are the cheap part. They end this CVE. They do not end the class. Five things to put in the same PR or the next one.

1. Treat Server Actions like RPC endpoints

Every file with 'use server' is part of your public attack surface. Audit them the way you would audit a /api route. Each action gets explicit argument validation (Zod, Valibot, hand-rolled, doesn't matter), an explicit size limit on the inbound payload, and an authentication check that does not assume the action only ran because your own form pointed at it. The form is a UI. The endpoint is a contract.

ts
"use server";
import { z } from "zod";
const Input = z.object({
email: z.string().email().max(254),
message: z.string().max(2000),
});
export async function submitContact(raw: unknown) {
const { email, message } = Input.parse(raw);
// ...
}

Input.parse throws if the request body does not match the schema, so the rest of submitContact only runs on inputs the application actually expects. That validation step is the part you were not writing six months ago, because the framework let you skip it.

2. Cap the parser

The Flight deserializer does not expose tunables you can set at the framework boundary, but your reverse proxy does. Add request-body size caps at the edge (Vercel, Cloudflare, your nginx) for any path that accepts Server Action payloads. A normal Server Action body is in the kilobytes. Sending a megabyte is already an event worth flagging.

3. Keep your WAF rules current

Cloudflare's existing rules 2694f1610c0b471393b21aef102ec699 and aaede80b4d414dc89c443cea61680354 cover CVE-2026-23870 generically.5 Other edge providers are rolling out similar rules over the next week. The patch is necessary; the WAF is the layer that catches the variant of the same bug that drops in three months.

4. Stop trusting cached RSC payloads

GHSA-wfc6-r584-vfw7, sitting next to CVE-2026-23870 in the same advisory window, is a cache-poisoning bug in RSC. The cache for serialized component output crosses the same trust boundary as the inbound request, and the same parser fragility applies on the way back out. Anywhere your app serves RSC payloads from a cache that an attacker can influence, the cache is in scope for this class of bug.

5. Read the protocol once

Twenty minutes with the community Flight writeups23 will change how you read the next CVE. The protocol is not complicated; the issue is that we treated it as out of scope. It is not.

The Pattern Behind the Pattern

Every generation of "framework magic" buries a new implicit protocol inside what looks like an ordinary API.

Server Components turned components into a wire format. tRPC turned function calls into RPCs (and was at least honest about it). GraphQL turned the schema into a network surface and then spent a decade explaining query-depth limits. WebSockets turned event handlers into a long-lived TCP session with backpressure and framing. Each of these is a real ergonomics win. Each of these moved a protocol boundary into a place developers do not habitually look.

The lesson is not "don't use the magic." The lesson is that when a framework dissolves a network boundary into developer ergonomics, the threat model still owes someone a postmortem. Usually the framework team writes it. Sometimes a CVE writes it instead.

Patch the parser. Cap the body. Read the protocol. The next Flight CVE is already being written somewhere, and the version after that, and the one after that. The version of you that wrote unvalidated Server Actions had a perfectly good reason: the framework told them they did not have to. The version of you reading this now knows better.


S
Written by
Sascha Becker
More articles