While
React Server Components rely on the custom Flight protocol to stream
interactive UIs, this same mechanism introduces powerful deserialization
sinks that attackers can exploit. Durgesh Pawar breaks down the
mechanics behind the CVSS 10.0 “React2Shell” vulnerability to show how
protocol manipulation can lead to remote code execution. It also covers a
practical, ranked set of defenses, from strict schema validation to
CSRF hardening, for securing React applications against these structural
risks.
React Server Components don’t send HTML to
your browser. They don’t send JSON either. When a server component
renders, what actually travels over the wire is a custom streaming
protocol called Flight. It’s a line-delimited format
with its own type system, its own reference resolution, and its own
rules for reconstructing executable behavior on the client.
Most
React developers have never opened the Network tab and actually looked
at a Flight payload. It looks like a mix of JSON fragments,
dollar-sign-prefixed references, and module pointers that the React
runtime silently reassembles into a live component tree. The framework
handles it, so nobody questions it.
I’m not sure most teams have thought carefully about what that trust actually implies.
I started pulling apart the Flight protocol after CVE-2025-55182 dropped in December 2025. The security community called it React2Shell,
and for good reason. It was a CVSS 10.0, unauthenticated remote code
execution vulnerability sitting in the Flight deserialization layer. One
crafted HTTP request to a Server Function endpoint, and an attacker had
shell access. No credentials needed.
The federal Cybersecurity & Infrastructure Agency (CISA) added it to the Known Exploited Vulnerabilities catalog. Sysdig
tied in-the-wild exploitation to North Korean state-sponsored actors
deploying file-less implants through the Ethereum blockchain. That’s the
kind of CVE that gets your attention.
After spending time in the source (mostly getOutlinedModel and getChunk,
which is where the resolution logic that matters actually lives), I
realized React2Shell wasn’t a one-off parsing bug. It was a symptom.
Flight reconstructs executable references, lazy-loaded components,
server RPC endpoints, and async state from a stream of text. That’s a deserialization system.
The attack surface extends well beyond a single missing hasOwnProperty
check. This article covers how Flight works on the wire, where the
deserialization sinks are, what attackers have already weaponized, and
what’s still exposed.
This leads to a ranked, practical set of defenses for your own Server Components: schema validation on every Server Action, the server-only package,
cross-site request forgery (CSRF) hardening beyond framework defaults,
and an assessment of what the Taint API and Web Application Firewalls
(WAFs) provide.
Flight On The Wire #
Open your browser’s Network tab on any Next.js App Router page and look for requests returning Content-Type: text/x-component.
That’s Flight. It’s not a single JSON blob. It’s a streaming,
line-delimited format where each line is a self-contained “row” that the
client-side React runtime processes as it arrives over the connection.
Here’s what a simple Flight payload looks like in practice:
Row 1 is an import directive. It tells the client to load ClientComponent.js from the bundler’s chunk map. Row 2 is a JSON tree that constructs an <article> HTML element, and the "$1" inside children is a reference back to chunk 1 (the imported component). Row 0 defines the server execution context, marking this as a RootLayout running in the Server
environment. Even in this tiny example, you can see the mix of
structural data, module references, and cross-chunk pointers that makes
Flight different from plain JSON.
Every row follows the same syntax: <ROW_ID>:<ROW_TAG><PAYLOAD>\n.
The row ID is a numeric identifier that other rows can reference. The
tag is a single character (or short string) that tells the parser what
kind of data follows. The payload is the actual content.
Here are the row tags I found while reading through the source:
| 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. |
So
far, this might look like a benign structured data format with some
custom tags, but the real complexity and attack surface live in the prefix system.
The $ Prefix System #
This is where I started paying closer attention.
When the client-side parser encounters a string value starting with $,
it doesn’t treat it as literal text. It intercepts the string, checks
the prefix, and routes it through a type-specific resolution path. The parseModelString function in ReactFlightClient.js is where this happens. It’s essentially a big switch statement on the character after $.
| 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 other prefix resolves a chunk and gives you the parsed result. $@ hands you the raw internal Chunk
object instead, the wrapper React uses to track resolution state,
pending callbacks, and internal metadata (which is why it’s used for
Promises and why exploits use it to get a mutable handle). Exposing
framework plumbing through the protocol looks like a design mistake to
me, though I’d be interested to hear the rationale if there is one.
And $: (property access) is the other critical prefix. It lets the protocol specify a path like $1:user:name, which tells the parser to resolve chunk 1, then access .user, then access .name
on the result. That’s arbitrary property traversal driven by data in
the stream. If you’ve spent any time auditing JavaScript for prototype
pollution, that pattern should feel familiar.
Flight is not JSON with extra steps. JSON gives you data. Flight gives you behavior.
It reconstructs module references that trigger client-side code
loading, creates server action endpoints the client can invoke as RPC
calls, sets up Promise chains that the React runtime will await, and builds lazy-loaded component boundaries that execute on demand.
Whether
React developers think of it that way or not, the mechanics look very
similar to deserialization systems that have historically caused
problems. The stream doesn’t just describe what the UI looks like. It
instructs the client runtime on what code to load, what functions to
call, and what to trust.
If you want to read the implementation
yourself, fair warning: the chunk resolution path is miserable to
follow. State transitions bounce between helper functions, and the
naming obscures what the code is actually doing. I gave up on static
reading and just set breakpoints. The key files are react-client/src/ReactFlightClient.js for the client-side parser (look for parseModelString, getChunk, reviveModel, and getOutlinedModel) and react-server/src/ReactFlightServer.js for the serialization side. The reply handler for Server Actions lives in react-server/src/ReactFlightReplyServer.js.
Why Flight Is A Deserialization Sink #
The deserialization pattern is familiar: Java’s ObjectInputStream gave us ysoserial, Python’s pickle executes code on load(), PHP’s unserialize chains __wakeup and __destruct methods, and .NET’s BinaryFormatter was deprecated entirely.
The pattern: deserialize attacker-controlled input → invoke behavior during reconstruction → lose control of execution.
So JavaScript should be immune to this, right? JSON.parse()
only produces plain data objects. No constructors fire. No magic
methods run. You get back exactly what the JSON string describes,
nothing more.
That’s true for raw JSON.parse(). But
it stops being true the moment a framework wraps custom deserialization
logic around it. And that’s exactly what Flight does.
Prototype Pollution #
JavaScript uses prototype-based inheritance. Every object has a __proto__ link to its prototype, and property lookups walk up this chain. If an attacker injects __proto__ or constructor.prototype
as a key during reconstruction, they modify the shared base prototypes
that all objects inherit from. Downstream code reads attacker-controlled
values without knowing.
Flight’s $: prefix performs property traversal on deserialized objects. The getOutlinedModel function walks colon-separated paths like $1:user:name by iterating through each segment and accessing it on the parent object. If those path segments include __proto__ or constructor, the traversal walks straight up the prototype chain. That’s not a theoretical risk. It’s exactly how React2Shell worked.
Duck Typing and Thenables #
The V8 engine (and the JavaScript spec) treats any object with a .then property as a Thenable. When you await something, the runtime checks for .then and calls it if it exists. No class check. No internal slot verification. If .then is callable, it gets invoked.
Flight resolves chunks asynchronously. If an attacker constructs an object with a manipulated .then
property and gets it into the chunk resolution pipeline, the runtime
calls the attacker’s function during normal await behavior. The language
semantics do the work.
I initially focused on $F because forging Server Action references seemed like the obvious attack surface. After tracing the resolution path, $:
property traversal looked much more interesting. I also spent a few
hours examining chunk status transitions (pending, blocked, resolved,
errored) to see if you could force a chunk into an unexpected state,
though that approach didn’t yield any results.
The Core Problem #
These two risks converge in Flight because the protocol doesn’t just deserialize data. It deserializes behavior. The $ prefix system dictates which execution path the parser takes: $F creates a callable server endpoint, $L sets up lazy code loading, $B triggers a blob handler, $@ exposes internal framework state. The parser’s control flow is driven entirely by what’s in the stream.
If
an attacker can influence the stream’s content, they control which
functions the parser calls, which objects it constructs, and which
internal state it exposes.
The Mechanics Of React2Shell #
This is the CVE that proved the theory. CVE-2025-55182,
nicknamed React2Shell, is a CVSS 10.0 unauthenticated remote code
execution vulnerability in the Flight deserialization layer. One HTTP
request, no login required, full shell access.
I want to walk
through the entire gadget chain because understanding it reveals how
much power the Flight protocol hands to an attacker who can control the
stream.
The Root Cause #
The vulnerability sits in getOutlinedModel, a function responsible for resolving deep property paths from the $: reference system. The instance used in the exploit chain lives in the server-side reply handling code (ReactFlightReplyServer.js). When the parser encounters a reference like $1:user:name, it splits on the colons and walks the path segment by segment. Here’s the vulnerable loop:
Two lines. No hasOwnProperty check. No validation that the property exists on the object itself rather than somewhere up the prototype chain. Just parentObject[reference[key]] and move on.
So an attacker supplies $1:__proto__:constructor:constructor, and the loop traverses from a plain JSON object up through Object.prototype to the Object constructor to the Function constructor. Function in JavaScript behaves like eval(). Function("arbitrary code")() executes.
No allowlist on property names. No check for __proto__. I searched reviveModel and the chunk initialization path for any filtering. Nothing.
The Gadget Chain #
Getting from “I can reach the Function constructor” to “I have RCE” requires chaining several Flight protocol features together. The Resecurity write-up covers the full chain in detail; here’s the high-level sequence:
- Step 1: Prototype walk to Function.
The $: path __proto__:constructor:constructor walks from any plain object to Object.prototype, then to the Object constructor, then to Function — JavaScript’s built-in eval() equivalent. - Step 2: Raw chunk self-reference.
$@0 returns the raw internal Chunk wrapper instead of its resolved value, giving the attacker a mutable handle on React’s internal state machine. - Step 3: Thenable hijack.
The attacker sets the chunk’s .then to Chunk.prototype.then, so React’s resolution pipeline treats the manipulated chunk as a legitimate Promise-like object and awaits it. - Step 4: Context confusion.
During the second deserialization pass, the payload overwrites _response._formData.get to point to the hijacked Function constructor and places the attacker’s shell command into _response._prefix. - Step 5: Trigger via blob handler.
$B0 invokes the blob handler, which internally calls response._formData.get(response._prefix + blobId) — now equivalent to Function("attacker_shell_command")(). That’s arbitrary code execution with whatever privileges the Node.js process has.
Each
step uses a legitimate Flight protocol feature in a way the designers
didn’t anticipate. There’s no single “broken” feature. The vulnerability
emerges from how these features compose when an attacker controls the
input.
Impact #
The numbers on this one are stark:
- CVSS 10.0. The maximum possible score.
- Unauthenticated
and pre-auth. No credentials needed — and the deserialization happens
before any application-level auth checks run, so even endpoints behind
login walls are exposed.
- Single HTTP request. One POST to a Server Function endpoint.
- Affected React 19.0.0, 19.1.0, 19.1.1, and 19.2.0, across
react-server-dom-webpack, react-server-dom-parcel, and react-server-dom-turbopack. - CISA added it to the Known Exploited Vulnerabilities catalog within days.
What Happened In The Wild #
Exploitation was immediate. Sysdig published research linking EtherRAT deployments to North Korean state-sponsored actors who weaponized the vulnerability within hours of disclosure. EtherRAT
is a file-less implant that uses the Ethereum blockchain for
command-and-control communication — a technique researchers call
“EtherHiding” — making takedown nearly impossible because you can’t
seize a blockchain.
Separately, Palo Alto’s Unit 42 documented a backdoor called KSwapDoor that masquerades as [kswapd1] on infected Linux systems, blending into process lists alongside the legitimate kswapd0
kernel swap daemon; their analysis confirms KSwapDoor uses RC4
encryption to protect its internal strings and configuration data, while
C2 communications run over AES-256-CFB with Diffie-Hellman key exchange
across a P2P mesh network. The speed and sophistication of these
campaigns — state-sponsored actors deploying novel implants through a
single unauthenticated HTTP request — underscores why a CVSS 10.0 in a
deserialization layer demands immediate patching, not triage.
The Fix #
The React team’s patch is clean and targeted. The core change caches the genuine hasOwnProperty method at module load time:
Then every property check in the deserialization path uses .call() to invoke the cached reference:
Even if an attacker shadows hasOwnProperty
on a malicious object, the check uses the original prototype method.
The prototype chain traversal that powered the gadget chain is blocked.
This fix shipped in React 19.0.1, 19.1.2, and 19.2.1.
The fix is
correct. But reading through the patches, I noticed the React team
hardened ownership checks while leaving the property traversal model
intact. The $: prefix still walks colon-separated paths; it
just validates each step now. I think exposing arbitrary property
traversal through a network protocol was a design mistake, and the patch
treats the symptom. If future bugs emerge, they’ll likely come from
this same area.
The framework patch closes the known gadget chain,
but it doesn’t change the fundamental dynamic: the Flight protocol
still reconstructs behavior — executable references, module imports, RPC
endpoints, async state — from a stream of text. That reconstruction
happens before your application code runs, before your validation logic
fires, before your auth middleware even sees the request. Relying solely
on the framework to protect your Server Components means trusting that
every edge case in a complex deserialization parser has been found and
fixed. The defenses that follow are the practical steps you can take to
limit the blast radius on your own.
Defenses, Ranked By Impact #
Some
of these close real attack paths. Others mostly make you feel safer
than you are. I’ve ranked these from most-to-least impactful based on
what I’ve seen in the vulnerability research. If you only have time for
one change, start at the top.
This
is the single most impactful thing you can do at the application level.
The Flight deserializer processes raw, unvalidated network input before
your code takes control. Strict schema validation is your primary
defense against whatever the protocol reconstructs.
Put a schema validation call at the very top of every Server Action, before any business logic runs — and I mean before anything,
including logging. If you log an argument before validating it, and
that argument triggers the stringification bug from CVE-2025-55183,
you’ve leaked source code before your validation even had a chance to
run.
Zod and Valibot
both work well for this. 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 the response if you’re not careful with your error boundaries.
One important nuance: If your Server Action accepts a plain object argument (not FormData),
validate the whole argument — don’t destructure first and validate
fields individually. Destructuring before validation means you’re
already accessing properties on the unvalidated input, which is exactly
the kind of operation the Flight deserializer can exploit.
If
your Server Action doesn’t start with a schema parse, it’s a
vulnerability waiting to happen. I’d argue this should be a lint rule —
and if you’re running eslint-plugin-react, consider writing a custom rule that flags any "use server" export without a validation call in its first statement.
2. The server-only Package #
The server-only package is straightforward and effective.
Import server-only
at the top of any file that contains database credentials, raw API
calls, internal business logic, or anything else that should never cross
the server-client boundary. If a Client Component tries to import that
file (directly or transitively), the build fails with a clear error.
The failure mode to watch for is barrel files. If you re-export a server-only function through an index.ts that also exports client-safe utilities, any Client Component importing from that barrel will pull in the server-only module transitively and break the build — or worse, if the barrel doesn’t include the server-only import itself, it may silently let server code through. Keep server-only modules in separate files with their own import paths.
It also won’t protect you from data leaking through return values. If a Server Component calls getUser() and passes the full user object (including passwordHash or internalRole) as props to a Client Component, that data rides the Flight stream to the browser. The server-only guard prevents the code from crossing the boundary, not the data the code returns. You must explicitly filter your return shapes.
3. CSRF Protections #
After CVE-2026-27978, relying solely on Next.js’s built-in Origin vs. Host header check isn’t enough. The Origin: null bypass showed that framework-level CSRF protection has edge cases.
For
state-changing Server Actions (anything that writes data, deletes
records, or modifies permissions), layer your own protections on top of
the framework’s defaults.
Cookie configuration.
Set SameSite=Strict or SameSite=Lax on session cookies. If you’re using next-auth or a custom session library, verify this is set explicitly — don’t rely on browser defaults, which vary.
Explicit CSRF tokens.
For
high-value operations (password changes, role assignments, payment
actions), generate a per-session CSRF token on the server, embed it in a
hidden form field or custom header, and validate it in the Server
Action before proceeding.
The allowedOrigins gotcha.
Never, under any circumstances, add 'null' to experimental.serverActions.allowedOrigins in your Next.js config (even if the officially advisory is more nuanced, saying “unless intentionally required and additionally protected”). That string literal matches Origin: null
— the exact header that sandboxed iframes send — and it reopens the
CVE-2026-27978 bypass. If you’re seeing CSRF failures from legitimate
requests, the fix is to configure your reverse proxy to set the correct Origin and Host headers, not to weaken the validation.
4. The hasOwnProperty Patch #
I covered this in detail in the React2Shell section. The fix is correct, and it completely neutralizes the known gadget chain. It shipped fast, which I respect.
The
action item here is to verify you’re actually running a patched
version. The RCE fix landed in React 19.0.1, 19.1.2, and 19.2.1. Check
your lockfile:
If
you see 19.0.0, 19.1.0–19.1.1, or 19.2.0, you’re vulnerable to the
RCE. Update immediately. And don’t stop there: the DoS fixes
(CVE-2025-55184, CVE-2025-67779, CVE-2026-23864) require 19.0.4+,
19.1.5+, or 19.2.4+. If you updated after React2Shell and then stopped
paying attention, you may still be running a version vulnerable to the
DoS variants.
It’s a reactive patch, not a structural redesign.
Note: More on that in Where This Goes Next.
5. The Taint API #
React’s taintObjectReference and taintUniqueValue
functions register objects or strings with the runtime. If tainted data
tries to pass through the Flight serializer, it throws an error. The
idea is to prevent sensitive data — user records, API keys, tokens —
from accidentally leaking into the client.
Here’s how it looks in practice:
If a Server Component passes the tainted user
object as props to a Client Component, React throws it at serialization
time with your custom error message. That’s genuinely useful as a
development-time guardrail.
The catch — and it’s a significant one — is that taint tracks object references, not data content. Any derivation breaks the tracking:
taintUniqueValue
works on specific strings (like API keys), but it’s also
reference-based. If the same key value appears in a different variable,
the taint doesn’t follow.
Think of taint as a development
guardrail, not a security boundary. It catches honest mistakes: a
developer accidentally passing a full user object to the client. It
won’t stop an attacker who can influence what gets serialized, and it
won’t survive routine data transformations that your own code performs.
It’s a useful defense-in-depth layer, but shouldn’t be your primary
boundary.
6. WAFs #
Web Application Firewalls can add a detection layer for known attack patterns. They can inspect POST requests carrying the Next-Action header, block payloads containing constructor:constructor or __proto__ chains, and flag error responses containing E{"digest" patterns that indicate the server is leaking internal error details.
If you’re running a WAF, here are specific patterns worth adding:
But
attackers know about WAF inspection buffers, and they’re usually around
128KB. Prepend 130KB of padding before the malicious payload, and the
WAF inspects the padding, finds nothing, and lets the request through.
Chunked Transfer-Encoding tricks accomplish the same thing.
The
failure mode is treating WAF coverage as a security boundary rather than
a noise-reduction layer. WAFs catch automated scanners and low-effort
attacks, and that has real value. But a motivated attacker will bypass
them with padding or encoding tricks. The defenses that actually stop
sophisticated attacks are the ones earlier in this list: validating
input before it reaches your business logic, keeping sensitive code off
the wire, and staying on patched versions.
What Came After React2Shell #
React2Shell
wasn’t the end of it. The security audits that followed the December
2025 disclosure shook out a series of related vulnerabilities in the
same deserialization surface. None of them are as severe as the original
RCE, but they’re worth tracking because some of them required multiple
rounds of patching.
| 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 |
The DoS pair (CVE-2025-55184
and CVE-2025-67779) is a textbook example of why deserialization
parsers are hard to patch correctly. The first fix shipped, researchers
found edge cases it missed, and a second round was needed.
CVE-2026-23864 added a third DoS vector through unbounded memory
allocation rather than CPU exhaustion. (See the defenses section above for specific version checks.)
CVE-2025-55183 is the sneaky one. It’s a source code exposure bug that triggers when a Server Function calls JSON.stringify
(or any implicit stringification) on one of its arguments. Developers
do this constantly for logging, debugging, or error reporting.
The
attacker sends a crafted argument that, when stringified, causes the
deserialization parser to reflect the function’s own source code back in
the response. Business logic, database queries, and any hardcoded
secrets sitting in Server Action files become readable by anyone who can
send an HTTP request.
CVE-2026-27978 is a different class of bug entirely. It’s a CSRF bypass in Next.js’s Server Action handling. Next.js validates that the Origin header matches the Host header to prevent cross-site request forgery. But when a request comes from a sandboxed <iframe>, the browser sends Origin: null.
The Next.js parser in action-handler.ts treated the string 'null'
as a missing origin rather than an explicit cross-origin indicator. So
an attacker could embed a form inside a sandboxed iframe, submit it, and
invoke Server Actions using the victim’s authenticated session cookies.
Fixed in Next.js 16.1.7.
What’s Still Exposed #
The CVEs above have patches. But some of the risk is structural, baked into how Flight is designed to work.
Man-In-The-Middle (MITM) On The Flight Stream #
If
an attacker can sit between server and client (CDN compromise, cache
poisoning, rogue proxy), modifying the Flight stream in transit looks
feasible. The format is plain text with a predictable structure.
Assuming stream control, an attacker could alter $I (Import) rows to redirect component loading to a different module in the webpack chunk map. They could inject $F (Server Reference) tags to embed hidden RPC triggers in the rendered UI. They could modify D (Data) rows to change component props, and if the target component uses dangerouslySetInnerHTML, that’s a direct XSS vector.
Flight escapes $
prefixes in user-supplied strings to prevent data from being
interpreted as protocol instructions. But that only applies to data
flowing through the serializer. A MITM attacker writes raw protocol
directly into the stream. The escaping doesn’t help.
Server Action Enumeration #
Server Action IDs are obfuscated hashes generated at build time. They look random. But server-reference-manifest.json
maps every action ID to its source implementation. A public manifest
hands an attacker a complete API map. This exposure usually stems from
misconfigured hosting, an exposed .next directory, or path traversal.
Known
action IDs expose Server Actions to standard IDOR and parameter
tampering attacks. An attacker can forge direct requests with
manipulated arguments. Developers often trust these inputs blindly
because they originate from React’s internal machinery. The
architectural consequences of that misplaced trust will be the focus of
my next piece.
Encrypted Closure Tampering #
When
a Server Action captures variables from its surrounding scope
(closures), Next.js encrypts them before sending to the client. The key
is in NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, AES with a base64-encoded key (16, 24, or 32 bytes). decryptActionBoundArgs handles decryption on each invocation.
By
default, this key regenerates every build. But multi-server setups
often use a static key. If an attacker gets file read access (path
traversal, SSRF), they extract the key, decrypt the closure state,
modify it (changing a userId, a role, a query parameter), and re-encrypt. The server accepts the forged closure as legitimate.
Supply Chain Activation via Module IDs #
I haven’t demonstrated this end-to-end, but the theory is straightforward.
Flight references client components by module ID, something like ["360","static/chunks/app/page-7f3480.js"]. The bundler assigns these IDs at build time based on the module graph. A compromised npm package sitting in node_modules as a transitive dependency gets bundled into a chunk but never loaded because no component references it. Inert.
But if an attacker injects $I
import references into the Flight stream (via MITM, cache poisoning, or
server-side injection), the parser should load that dormant module.
There may be chunk-level validation I’m not seeing. But if the module ID
is valid and present in the manifest, I don’t see what stops it. The
attack doesn’t require the package to be imported anywhere in your code.
It just needs to exist in the bundle output.
This Has Happened Before #
React
Flight isn’t the first framework to invent a custom serialization
format for server-client communication and then discover it’s an attack
surface. And it won’t be the last.
Google Web Toolkit (GWT)
used a custom RPC protocol to sync Java objects between browser and
server. BishopFox demonstrated that attackers could manipulate the wire
format to achieve arbitrary deserialization; GWT eventually disabled
binary serialization entirely. It took years.
Java Server Faces (JSF) and ASP.NET both serialized ViewState
to the client as a hidden form field. When cryptographic signing was
weak or missing, attackers tampered with the serialized state and
achieved remote code execution. Microsoft and Oracle patched it
repeatedly. The underlying pattern kept resurfacing.
The pattern
is always the same: a framework invents a custom wire format to move
rich, stateful, sometimes executable data between server and client. The
designers assume the server is the sole producer of that data and the
client is a trusted consumer. Then someone demonstrates that the wire
format can be manipulated in transit, or that the server can be tricked
into deserializing attacker-controlled input. React Flight is the latest
entry in this pattern. It is not an anomaly.
Where This Goes Next #
The
React Flight protocol solves a genuinely hard problem: streaming
interactive component trees from server to client in a way that enables
progressive hydration, async data loading, and server-driven code
splitting. It works. I don’t want to lose sight of that.
But it
works by serializing executable references, async state, module
pointers, and RPC endpoints over a streaming text protocol, and then
trusting the structure of that stream on both ends. The React team has
patched the known gadgets. The hasOwnProperty fix is correct. The DoS fixes are in place. The source code exposure bug is closed.
I think exposing arbitrary property traversal and executable Thenable reconstruction through a network-facing protocol was a design mistake. $:, $@, and $B
are powerful internal primitives that were reachable through a parser
that didn’t validate ownership of the properties it traversed. One check
was missing, and the result was 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.
Hoping the parser handles every edge case hasn’t worked historically, and I don’t see why it would start working now.
The code is in react-client/src/ReactFlightClient.js. If you ship Server Components, read it. Know what your framework is trusting on your behalf.