[Flight] decodeReply, decodeReplyFromBusboy and decodeReplyFromAsyncIterable return different results for the same bytes
Summary
React ships three decoders for the Server Action reply format and they return different results for the same bytes:
decodeReplydecodeReplyFromBusboydecodeReplyFromAsyncIterable
Next.js selects between the first two by runtime — Edge turns the body into a web FormData and calls decodeReply, Node pipes it through busboy — and export const runtime = 'edge' is a per-route setting, so both commonly exist in one application. The same Server Action, given the same request bytes, can receive different arguments depending on where the route runs.
React version
[email protected], [email protected] (current stable). Also reproduced on 19.3.0-canary-1d34f91d-20260909 with identical results.
Steps to reproduce
npm i [email protected] [email protected] [email protected] busboy
node --conditions=react-server hunt-differential.mjs
The script builds the same logical entries as a multipart body and decodes it both ways.
Current behaviour
CASE decodeReply decodeReplyFromBusboy
plain field only ok:{a:1} ok:{a:1}
FD values then ref ok:{a:[x=A]} ok:{a:[x=A]}
ref then FD values ok:{a:[x=A]} ok:{a:[]} <-- differs
two FD interleaved ok:{x:[a=A],y:[b=B]} ok:{x:[a=A],y:[b=B]}
blob before ref ok:{t:TA(2)} ok:{t:TA(2)}
blob after ref ok:{t:TA(2)} rej:TypeError <-- differs
decodeReplyFromAsyncIterable disagrees on a third axis: a forward chunk reference is left pending where the other two resolve it, so the application receives a promise that never settles.
Over 400 randomly generated multipart layouts:
payloads: 400 differentials: 216 (54.0%)
148 one decoder accepts, the other rejects
68 BOTH accept and hand the application DIFFERENT VALUES
The 68 are the interesting ones.
Expected behaviour
Which of the three decoders happens to run should not change the arguments an application receives. Either all three accept a body and agree on its value, or all three reject it.
Why they disagree
decodeReply receives the whole FormData up front, so a reference resolves against an entry anywhere in the body.
The streaming decoders resolve as entries arrive, and the $K FormData reconstruction walks a shared cursor over the backing store (ReactFlightReplyBackingFormData.js), assuming each FormData's values arrive contiguously and immediately before its reference. decodeReplyFromBusboy maintains a linked list of pending files whose stated purpose is to make the backing FormData's insertion order match the payload's entry order, so the invariant is known and load-bearing — this is the same area as #36468.
React's own encoder always satisfies that invariant: encodeReply -> decodeReply round-trips losslessly over ten shapes I tried (single / two / three FormData, reversed, the same object twice, duplicate keys, empty, nested, mixed with a Blob, arrays of FormData). The divergence only shows up for hand-written bodies.
Impact
I am filing this as a correctness bug, not a security report, and I want to be precise about why.
A client composes the whole body, so choosing which of several results the application sees is not by itself a privilege gain — the same client could send either shape directly. There is no bypass here.
It matters where the same bytes are parsed twice by different code and the conclusions are compared or relied on: an Edge middleware that inspects an action body in front of a Node route handler, or a proxy, WAF or audit log that parses the multipart itself. That requires a specific application pattern, so I state it as a scenario rather than a demonstrated problem.
Operationally it is simpler: an action that works on Edge and 500s on Node for identical input, in 148 of 400 payloads.
Suggested fix
Either buffer in the streaming decoders until the body ends before resolving references, matching decodeReply, or have decodeReply enforce the same ordering invariant so all three reject the same bodies. The part worth removing is silently producing different answers for one input.
Note
Next.js is the consumer that selects between the decoders. I have not filed there; if it would be better coordinated on that side, say so and I will.
Source: facebook/react