Flight Is Not a Data Format
React Server Components don’t ship HTML or JSON to the browser. When a server component renders, it produces a custom streaming protocol called Flight: a line-delimited format with its own type system, reference resolution, and rules for reconstructing executable behavior client-side. Most developers never inspect a Flight payload; the framework consumes it silently, reassembling references into a live component tree.
That trust deserves scrutiny, and the December 2025 disclosure of CVE-2025-55182 — dubbed React2Shell — proved it. The CVSS 10.0, unauthenticated remote code execution vulnerability sat in the Flight deserialization layer. A single crafted HTTP request to a Server Function endpoint yielded shell access with no credentials. CISA added it to the Known Exploited Vulnerabilities catalog, and Sysdig documented in-the-wild exploitation by North Korean state-sponsored actors deploying file-less implants through the Ethereum blockchain.
After auditing the relevant source — primarily getOutlinedModel and getChunk, where resolution logic actually lives — it becomes clear React2Shell wasn’t an isolated parsing bug. It was a symptom of a deeper structural issue: Flight reconstructs executable references, lazy-loaded components, server RPC endpoints, and async state from text. That makes it a deserialization system, and the attack surface extends far beyond a single missing hasOwnProperty check.
How Flight Travels Over the Wire
Open the Network tab on any Next.js App Router page and look for responses with Content-Type: text/x-component. That’s Flight. Each line is a self-contained row the client processes as it arrives. A simple payload mixes structural data, module imports, and cross-chunk pointers:
1:I["./src/components/ClientComponent.js",["chunks/main.js"],"default"]
2:J["$","article",null,{"children":"$1"}]
0:D{"name":"RootLayout","env":"Server"}
Row 1 is an import directive telling the client to load ClientComponent.js. Row 2 constructs an <article> element, where "$1" inside children references the previously imported chunk. Row 0 defines the server execution context. Even in miniature, the format interleaves three distinct concerns that plain JSON never combines.
Row Structure and Tags
Every row follows the syntax <ROW_ID>:<ROW_TAG><PAYLOAD>\n. The numeric ID lets other rows reference it. The tag tells the parser what kind of data follows. A survey of the source reveals these tags:
| Tag | Name | What it does |
|---|---|---|
| J | JSON Tree | Serialized virtual DOM nodes, component props, and HTML elements. |
| M | Module | Metadata for a specific Client Component module or chunk. |
| I | Import | Tells the client to load a module from the bundler’s chunk map. |
| HL | Hint/Preload | Instructs the browser to preload resources such as stylesheets or fonts. |
| D | Data | Server-rendered element context and environment info. |
| E | Error | Serialized server-side exceptions and error boundaries. |
The real complexity — and the attack surface — lives in the prefix system, not the row tags.
The $ Prefix Resolution Path
When the client-side parser encounters any string starting with $, it stops treating it as literal text. It intercepts the value, inspects the following character, and dispatches through a type-specific resolution path. The parseModelString function in ReactFlightClient.js implements this as a large switch statement:
| Prefix | Type | What the parser does with it |
|---|---|---|
$ | Model Reference | Resolves to another chunk in the stream (e.g., $2 points to row 2). |
$: | Property Access | Traverses into a resolved chunk’s properties (e.g., $1:user:name). |
$S | Symbol | Creates a native JavaScript Symbol. |
$F | Server Reference | Represents a callable Server Action (an RPC endpoint on the server). |
$L | Lazy Component | Defers component loading until it’s needed in the render tree |
$@ | Promise/Raw Chunk | Returns the internal Chunk wrapper object itself (often acting as a Thenable/Promise), not its resolved value. |
$B | Blob/Binary | Triggers the blob deserialization handler for binary data. |
Every prefix except $@ resolves a chunk and yields the parsed result. $@ hands back the raw internal Chunk object — the wrapper React uses to track resolution state, pending callbacks, and metadata (hence its use for Promises, and why exploits use it as a mutable handle). Exposing framework internals through the protocol appears to be a design flaw, although the rationale could merit discussion.
The $: prefix handles property access. A path like $1:user:name instructs the parser to resolve chunk 1, then traverse .user, then .name on the result. That is arbitrary property traversal driven entirely by stream data — a pattern any JavaScript auditor will recognize from prototype pollution research.
Flight is therefore not JSON with extra steps. JSON yields data; Flight yields behavior. It reconstructs module references that trigger code loading, creates server action endpoints usable as RPC calls, sets up Promise chains for await to resolve, and builds lazy-loaded boundaries that execute on demand. The stream doesn’t merely describe the UI; it instructs the runtime on what code to load, what functions to call, and what to trust.
For those reading the implementation: the chunk resolution path is convoluted, with state transitions scattered across helpers and naming that obscures intent. Static reading proved unproductive; breakpoints worked better. Key files are react-client/src/ReactFlightClient.js (look for parseModelString, getChunk, reviveModel, and getOutlinedModel) and react-server/src/ReactFlightServer.js for serialization. The reply handler for Server Actions lives in react-server/src/ReactFlightReplyServer.js.
Why Flight Qualifies as a Deserialization Sink
Deserialization flaws follow a recognizable pattern across languages: Java’s ObjectInputStream produced ysoserial, Python’s pickle executes code on load(), PHP’s unserialize chains magic methods, and .NET deprecated BinaryFormatter. Raw JSON.parse() appeared immune: no constructors fire, no magic methods run, and output contains exactly what the string describes.
The pattern: deserialize attacker-controlled input → invoke behavior during reconstruction → lose control of execution.
That immunity ends the moment a framework layers custom deserialization over the JSON parser — which is precisely what Flight does.
Prototype Pollution Through Property Traversal
JavaScript’s prototype-based inheritance means every object links to a prototype via __proto__, and lookups walk that chain. If an attacker can inject __proto__ or constructor.prototype as keys during reconstruction, they mutate shared base prototypes; downstream code reads attacker-controlled values unknowingly.
Flight’s $: prefix performs property traversal on deserialized objects. The getOutlinedModel function walks colon-separated paths segment by segment, accessing each on the parent. When segments include __proto__ or constructor, traversal moves up the prototype chain. This was not theoretical — it is exactly how React2Shell executed.
Duck Typing and Thenables
V8 and the ECMAScript spec treat any object with a .then property as a Thenable. Awaiting such a value prompts the runtime to check for .then and invoke it if callable — no class check, no internal slot verification. If an attacker places an object with a manipulated .then into the chunk resolution pipeline, the runtime invokes the attacker’s function during ordinary await semantics.
While $F initially seemed like the obvious surface for forging Server Action references, tracing the resolution path revealed $: property traversal as far more interesting. Examining chunk status transitions (pending, blocked, resolved, errored) to force an unexpected state yielded no findings, but the convergence of these risks remains: Flight deserializes behavior, not just data. The $ prefix system dictates the parser’s execution path — $F creates callable endpoints, $L loads code lazily, $B triggers blob handlers, $@ exposes internal state. Attacker control over stream content translates directly into control over which functions the parser invokes, which objects it constructs, and which internal state it exposes.
Anatomy Of A 10.0
CVE-2025-55182 — “React2Shell” — is a CVSS 10.0 unauthenticated remote code execution vulnerability in React’s Flight deserialization layer. It takes one HTTP request with no credentials to get a shell. The root cause is in getOutlinedModel, a function in ReactFlightReplyServer.js that resolves deep property paths from the $: reference syntax. These identifiers are parsed by splitting on colons and traversing the object graph one segment at a time.
The vulnerable loop is sparse:
for (key = 1; key < reference.length; key++)
parentObject = parentObject[reference[key]];
There is no hasOwnProperty guard, no allowlist of property names, and no filter for __proto__. A crafted path like $1:__proto__:constructor:constructor climbs from a plain JSON object through Object.prototype and into the Function constructor. In JavaScript, Function("code")() is effectively eval(). A search of reviveModel and the chunk initialization path turns up no filtering anywhere in that flow.
The Gadget Sequence
Reaching the Function constructor gets an attacker partway. Completing the exploit chains several legitimate Flight features into a full RCE, as documented in the Resecurity write-up:
- Prototype walk to Function. The
$:path__proto__:constructor:constructorlands on JavaScript’s built-ineval()equivalent. - Raw chunk self-reference.
$@0salvages the internalChunkwrapper instead of its resolved payload, yielding a mutable handle on React’s internal state. - Thenable hijack. Swapping the chunk’s
.thenforChunk.prototype.thenfools the resolution pipeline into awaiting a manipulated object as a Promise-like. - Context confusion. On the second deserialization pass, the payload redirects
_response._formData.getto theFunctionconstructor, parking the shell command in_response._prefix. - Blob handler trigger.
$B0fires the blob path, which callsresponse._formData.get(response._prefix + blobId)— now equivalent to executing the attacker’s command with Node.js process privileges.
No single feature is obviously broken. The flaw emerges from composition: the protocol’s features were not designed with an attacker-controlled input in mind.
Impact And Aftermath
The exposure profile is severe:
- CVSS 10.0 — the maximum score.
- Unauthenticated and pre-auth; deserialization runs before application-level authentication checks.
- A single POST request to a Server Function endpoint.
- Affects React 19.0.0, 19.1.0, 19.1.1, and 19.2.0, across
react-server-dom-webpack,react-server-dom-parcel, andreact-server-dom-turbopack. - Added to CISA’s Known Exploited Vulnerabilities catalog within days of disclosure.
Weaponization was immediate. Sysdig research ties EtherRAT deployments — a file-less implant using Ethereum blockchain for C2, a technique dubbed “EtherHiding” — to North Korean state-sponsored actors within hours of disclosure. Palo Alto’s Unit 42 separately found a Linux backdoor called KSwapDoor that masks itself as [kswapd1] next to the legitimate kernel swap daemon, with RC4-encrypted internals and AES-256-CFB C2 traffic secured via Diffie-Hellman over a P2P mesh. The speed of these campaigns is the point: state actors turned a deserialization bug into novel implants in hours. A CVSS 10.0 here means patching immediately, not triaging.
The Patch And Its Limits
React’s fix is small and precisely aimed. At module load, the genuine hasOwnProperty is cached:
var hasOwnProperty = Object.prototype.hasOwnProperty;
Every property check in the deserialization path now invokes that cached reference via .call():
hasOwnProperty.call(value, i);
Shadowing hasOwnProperty on a malicious object is ineffective — the original prototype method is used regardless. The prototype-chain traversal behind the gadget chain is cut off. The fix ships in React 19.0.1, 19.1.2, and 19.2.1.
The patch is sound, but it preserves the underlying design. The $: reference system still supports arbitrary property traversal over the network; it merely checks ownership at each step. Treating a protocol that reconstructs executable references and async state from text as inherently risky is the harder lesson. Validation and authentication run too late — after deserialization has fully reconstructed the server-side runtime model. The framework-side fix closes one known chain; defending anything beyond that requires limits on the blast radius that only the application can impose.
Ranking the Fixes That Matter
Not every mitigation closes a real attack path. Based on what the vulnerability research has shown, these defenses are ordered from most to least impactful. If you only make one change, start at the top.
Server Action Input Validation
This is the single highest-impact application-level fix available. The Flight deserializer processes unvalidated network input before your code ever gains control. Strict schema validation is your primary defense against whatever the protocol reconstructs.
Place a schema validation call at the very top of every Server Action, before any business logic — including logging. If you log an argument before validating it, and that argument triggers the CVE-2025-55183 stringification bug, you’ve leaked source code before validation could run.
Zod and Valibot both handle this well. Validate types, shapes, string lengths, numeric bounds, and enumerated values. Reject anything that doesn’t match. Use .safeParse(), not .parse() — the throwing variant can surface internal error details in responses if error boundaries aren’t carefully configured.
"use server"
import { z } from "zod"
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["user", "editor"]),
})
export async function updateProfile(formData: FormData) {
const parsed = UpdateProfileSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
})
if (!parsed.success) return { error: "Invalid input" }
// proceed with parsed.data, this is now the only shape
// your business logic ever sees
}
Key nuance: If your Server Action receives a plain object argument, validate the whole object — don’t destructure first and validate fields individually. Destructuring accesses properties on unvalidated input, which is exactly the operation the Flight deserializer can exploit.
"use server"
import { z } from "zod"
const CommentSchema = z.object({
postId: z.string().uuid(),
body: z.string().min(1).max(5000),
})
// Good: validate the raw argument first
export async function addComment(data: unknown) {
const parsed = CommentSchema.safeParse(data)
if (!parsed.success) return { error: "Invalid input" }
await db.comments.create(parsed.data)
}
// Bad: destructuring before validation
export async function addCommentUnsafe(
{ postId, body }: { postId: string; body: string }
) {
// by the time this runs, you've already accessed properties
// on the deserialized input
const parsed = CommentSchema.safeParse({ postId, body })
// ...
}
A Server Action that doesn’t begin with a schema parse is a latent vulnerability. This deserves a lint rule; if you run eslint-plugin-react, consider a custom rule that flags any "use server" export missing a validation call in its first statement.
The server-only Package
Import server-only at the top of any file holding database credentials, raw API calls, internal business logic, or anything that must never cross the server-client boundary. A Client Component importing that file — directly or transitively — fails the build with a clear error.
import "server-only"
import { db } from "./database"
export async function getUser(id: string) {
return db.query("SELECT * FROM users WHERE id = $1", [id])
}
Watch for barrel files. Re-exporting a server-only function through an index.ts that also exposes client-safe utilities means any Client Component importing from that barrel pulls in the server-only module and breaks the build. Worse, if the barrel itself lacks the server-only import, server code can slip through silently. Keep server-only modules in separate files with dedicated import paths.
// Don't do this: barrel re-export mixes boundaries
// src/utils/index.ts
export { getUser } from "./users" // has "server-only"
export { formatDate } from "./dates" // client-safe
// Do this: separate import paths
// Client Component imports from "src/utils/dates" directly
// Server Component imports from "src/utils/users" directly
This package does not prevent data leaks through return values. A Server Component calling getUser() and forwarding the full user object — passwordHash, internalRole, everything — as props to a Client Component rides the Flight stream to the browser. The guard stops code from crossing the boundary, not the data that code returns. Filter return shapes explicitly.
CSRF Hardening
After CVE-2026-27978, Next.js’s built-in Origin versus Host header check alone is insufficient. The Origin: null bypass demonstrated framework-level CSRF edge cases.
For any state-changing Server Action — writes, deletions, permission modifications — layer additional protections on top of framework defaults.
Cookie configuration. Set SameSite=Strict or SameSite=Lax on session cookies. With next-auth or a custom session library, verify this explicitly; browser defaults vary.
// next.config.js or your auth configuration
cookies: {
sessionToken: {
name: "__session",
options: {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
},
},
}
Explicit CSRF tokens. For high-value operations — password changes, role assignments, payment actions — generate a per-session token server-side, embed it in a hidden field or custom header, and validate it in the Server Action before proceeding.
"use server"
import { cookies } from "next/headers"
import { validateCsrfToken } from "@/lib/csrf"
export async function deleteAccount(formData: FormData) {
const token = formData.get("csrf_token") as string
const sessionToken = (await cookies()).get("csrf_secret")?.value
if (!validateCsrfToken(token, sessionToken)) {
return { error: "Invalid request" }
}
// proceed with deletion
}
The allowedOrigins trap. Never add 'null' to experimental.serverActions.allowedOrigins in your Next.js configuration. The advisory’s nuanced “unless intentionally required and additionally protected” language doesn’t change the risk: that string literally matches Origin: null, the exact header sandboxed iframes send, reopening CVE-2026-27978. Legitimate requests failing CSRF should be fixed by configuring your reverse proxy to set correct Origin and Host headers — not by weakening validation.
// Never do this
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ["null"], // reopens CSRF bypass
},
},
}
The hasOwnProperty Patch
The fix itself is correct and fully neutralizes the known React2Shell gadget chain. Verify your runtime version. The RCE fix shipped in React 19.0.1, 19.1.2, and 19.2.1.
# npm
npm ls react react-dom react-server-dom-webpack
# pnpm
pnpm ls react react-dom react-server-dom-webpack
# yarn
yarn why react-server-dom-webpack
19.0.0, 19.1.0–19.1.1, and 19.2.0 remain vulnerable to the RCE — update immediately. The DoS fixes (CVE-2025-55184, CVE-2025-67779, CVE-2026-23864) require 19.0.4+, 19.1.5+, or 19.2.4+. Updating after React2Shell and stopping leaves systems exposed to newer DoS variants. This is a reactive patch, not a structural redesign.
The Taint API
React’s taintObjectReference and taintUniqueValue register objects or strings with the runtime. Tainted data passing through the Flight serializer throws an error, preventing accidental leaks of user records, API keys, or tokens into the client.
import {
experimental_taintObjectReference as taintObjectReference
} from "react"
import "server-only"
export async function getUserRecord(id: string) {
const user = await db.users.findUnique({ where: { id } })
taintObjectReference(
"Do not pass the full user object to Client Components. " +
"Select only the fields you need.",
user
)
return user
}
Passing a tainted user object as props to a Client Component triggers the error at serialization time. It’s genuinely useful as a development-time guardrail.
The limitation: taint tracks object references, not data content. Any derivation breaks tracking.
const user = await getUserRecord(id)
// taint is lost. Spread creates a new object.
<ClientProfile user={{ ...user }} />
// taint is lost. Individual properties aren't tracked.
<ClientProfile token={user.apiToken} />
// taint is lost. Serialization round-trip creates new refs.
<ClientProfile user={JSON.parse(JSON.stringify(user))} />
// taint fires. Same object reference.
<ClientProfile user={user} />
taintUniqueValue handles specific strings like API keys but remains reference-based — the same key value in a different variable escapes taint entirely.
Treat taint as a development guardrail, not a security boundary. It catches honest mistakes like a developer forwarding a full user object. It will not stop an attacker capable of influencing serialization, and routine data transformations bypass it. It’s defense-in-depth, not the primary boundary.
WAFs as Noise Reduction
Web Application Firewalls add detection for known payload shapes: POST requests with the Next-Action header, bodies containing constructor:constructor or __proto__ chains, and error responses with E{"digest" patterns signaling internal error leaks.
# Block prototype pollution attempts in request bodies
Rule: body contains "__proto__" OR "constructor:constructor"
Action: BLOCK
Scope: POST requests with header "Next-Action"
# Flag potential Flight error leakage in responses
Rule: response body matches /E\{"digest":"[^"]+"/
Action: LOG + ALERT
Scope: responses with Content-Type "text/x-component"
# Block excessively large Server Action payloads
Rule: Content-Length > 1MB for POST with "Next-Action" header
Action: BLOCK (mitigates CVE-2026-23864 zipbomb vector)
Attackers account for WAF inspection buffers, typically around 128KB. Prepending 130KB of padding pushes the malicious payload past inspection; chunked Transfer-Encoding achieves the same. Treating WAF coverage as a security boundary is the failure mode.
WAFs catch automated scanners and low-effort attacks — real value. But padding and encoding bypasses are trivial. The defenses that stop sophisticated attacks are the earlier ones: validating input before business logic, keeping sensitive code off the wire, and staying patched.
The Post-React2Shell Vulnerability Cascade
React2Shell opened the floodgates. Security audits following the December 2025 disclosure found a series of related issues in the same deserialization surface. None match the original RCE severity, but several required multiple patch rounds.
| CVE | CVSS | Type | Description | Fixed In |
|---|---|---|---|---|
| CVE-2025-55184 | 7.5 | DoS | Infinite recursion of nested Promises in Server Function deserialization. Hangs the Node.js event loop. | 19.0.2, 19.1.3, 19.2.2 |
| CVE-2025-67779 | 7.5 | DoS | Incomplete fix for CVE-2025-55184. Same loop via edge cases the first patch missed. | 19.0.4, 19.1.5, 19.2.4 |
| CVE-2026-23864 | 7.5 | DoS/OOM | Unbounded request body buffering and zipbomb-style decompression. Memory exhaustion. Disclosed Jan 2026. | 19.0.4+, 19.1.5+, 19.2.4+ |
| CVE-2025-55183 | 5.3 | Info Disclosure | Crafted requests reflect Server Function source code when the function stringifies an argument. | 19.0.1, 19.1.2, 19.2.1 |
| CVE-2026-27978 | 5.3 | CSRF Bypass | Next.js treated Origin: null (sandboxed iframes) as “missing” instead of “cross-origin.” | Next.js 16.1.7 |
CVE-2025-55184 and CVE-2025-67779 form a textbook case in why deserialization parsers resist correct patching. The first fix shipped, researchers found edge cases, and a second round followed. CVE-2026-23864 added a third DoS vector via unbounded memory allocation rather than CPU exhaustion. (Version-specific guidance is in the defenses section above.)
CVE-2025-55183 is the subtle one: source code exposure triggered when a Server Function calls JSON.stringify — or any implicit stringification — on an argument. Developers do this constantly for logging, debugging, and error reporting. A crafted argument causes the parser to reflect the function’s own source code back in the response. Business logic, database queries, and hardcoded secrets in Server Action files become readable to anyone able to send an HTTP request.
CVE-2026-27978 is a different class entirely: a CSRF bypass in Next.js Server Action handling. Next.js validates that Origin matches Host, but browsers send Origin: null from sandboxed <iframe>s. The parser in action-handler.ts treated the string 'null' as a missing origin rather than an explicit cross-origin signal. An attacker could embed a form in a sandboxed iframe and invoke Server Actions with the victim’s authenticated session cookies. Fixed in Next.js 16.1.7.
Residual Risk After the Patches
The fixed CVEs closed specific, reachable sinks. But the protocol’s architecture leaves several classes of attack open even against a fully patched React. These are not bugs in the usual sense; they are consequences of the format’s design.
Interception and Stream Tampering
The Flight wire format is plain text with a regular, predictable shape. Any party capable of sitting between the server and the client — a compromised CDN, a poisoned cache, a rogue proxy — can alter rows while they are in transit. That is a fundamental property of the protocol; the escaping that prevents user data from being parsed as directives only protects data that passed through the serializer. A man-in-the-middle writes raw protocol bytes, bypassing that protection entirely.
With stream control, an attacker can:
- Change
$I(Import) entries to point component loading at arbitrary modules in the webpack chunk map. - Inject
$F(Server Reference) tags to embed hidden remote procedure call triggers inside the rendered UI. - Alter
D(Data) rows to modify component props. If a targeted component renders props throughdangerouslySetInnerHTML, tampering becomes a direct XSS vector.
Server Action ID Exposure
Action IDs look like random build-time hashes, but the manifest file server-reference-manifest.json maps every ID to its implementation. A publicly reachable manifest — through a misconfigured host, an exposed .next directory, or path traversal — hands an attacker a complete inventory of server functions. Known IDs make Server Actions vulnerable to IDOR and argument tampering attacks that normally require deeper reverse engineering. Because the IDs come from React’s internals rather than user input, developers tend to trust these requests implicitly.
Closure State Forgery
Server Actions that capture surrounding scope encrypt their bound arguments before sending them to the client. Next.js uses AES with a base64-encoded key of 16, 24, or 32 bytes, stored in NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, and decrypts on every invocation via decryptActionBoundArgs. The key rotates each build by default, but multi-server deployments frequently pin a static key. File read access — via path traversal or SSRF — lets an attacker retrieve that key, decrypt the closure state, modify a userId, role, or query parameter, and re-encrypt. The server accepts the forged closure as a legitimate invocation.
Dormant Module Activation
A less proven but plausible supply chain angle concerns module IDs. The Flight stream references client components by bundled IDs such as ["360","static/chunks/app/page-7f3480.js"]. The bundler assigns those IDs from the module graph at build time. A compromised transitive dependency in node_modules can be bundled into a chunk but never loaded, because nothing in the component tree imports it. That leaves it inert.
The theory is that an attacker who can inject $I import rows — via MITM, cache poisoning, or server-side injection — could cause the parser to load that dormant module. It remains unverified whether chunk-level validation prevents this. If the module ID is valid and present in the manifest, no obvious barrier stops the load. The package would not need to be referenced anywhere in application code; existing in the bundle output would suffice.
A Recurring Framework Failure
React Flight is not the first framework to build a custom wire format for server-client communication and later discover the format is an attack surface.
Google Web Toolkit used a bespoke RPC protocol to sync Java objects between the browser and the server. BishopFox demonstrated that manipulating the wire format led to arbitrary deserialization; GWT eventually disabled binary serialization after years of patching. Java Server Faces and ASP.NET both serialized ViewState into a hidden form field, and when signing was weak or missing, tampering led to remote code execution. Both vendors patched repeatedly; the underlying pattern kept reappearing.
The common thread is consistent: a framework invents a custom format to move rich, stateful, sometimes executable data between endpoints, assumes the server is the sole producer and the client a trusted consumer, and then someone shows the format can be manipulated in transit or that the server can be tricked into deserializing attacker input. React Flight is the current entry. It is not an anomaly.
Assessment
Flight solves a genuinely difficult problem: streaming interactive component trees from the server to support progressive hydration, async data, and server-driven code splitting. It works. That should not be overlooked.
But the mechanism serializes executable references, async state, module pointers, and RPC endpoints over a streaming text protocol, and trusts the stream structure at both ends. The React team patched the known gadgets — the hasOwnProperty fix, the DoS issues, and the source exposure bug are closed. Exposing arbitrary property traversal and executable Thenable reconstruction through a network-facing parser was a design mistake. $:, $@, and $B are internal primitives that were reachable because the parser did not validate ownership of the properties it walked. One missing check produced a CVSS 10.0.
As more frameworks adopt server-driven UI patterns, the industry is going to need stronger primitives than “the server is trusted”: cryptographic validation of serialized payloads, signed component trees, and content integrity checks on the Flight stream itself.
Relying on the parser to handle every edge case has not worked historically, and there is no evidence it will start now. The relevant code is in react-client/src/ReactFlightClient.js. If you ship Server Components, read it. Understand what your framework trusts on your behalf.




