現在、React2Shell(CVE-2025-55182) で世間は大騒ぎかと思います。
自分もこれについて理解のために調査を行っていますが、「そもそも Flight Protocol ってなんだ?」という点を必ず通ると思います。
そのため、個人的な理解ではありますが、理解した内容をまとめます。
Flight Protocol とは
React Server Component(RSC) で利用されている独自のシリアライズフォーマットです。React 18 で導入され、Next.js では App Router で活用されています。
クライアントとサーバーの間で React Compoenent を含むデータを効率的に転送するために設計され、Promise など非常に複雑多様な型を取り扱えます。
特徴として、chunk と呼ばれる単位に分けられ、この chunk は相互参照可能になっています。それぞれが Promise-like なオブジェクトで、pending/fulfilled といったステータスを持ち、内部的には Promise.prototype を継承しています。また、$ を接頭辞とする特殊な記号を用いて複雑な処理が可能です。
具体的には Next.js の Server Actions を実装する際に役立ちます。
歴史的な経緯
従来の React において、クライアントサイドにおけるデータフェッチに伴い、バンドルサイズの増加やネットワークのウォーターフォールが発生していました。
これの解決のため、レンダリングのみならずデータアクセスについてもサーバー側で行うよう RSC が提案され、Flight Protocol はそのシリアライズ手法として生まれたものと思われます。
Flight Protocol を観測する
本来は Server Actions により「クライアントが受け取った HTML に JS が含まれない」という状況を作り出し、JS 非対応環境でも動作させるようにする、といったことが利点としてあります。
ただ、ここでは Flight Protocol の観測がしたいので、上記を潰して下記のような簡単な検証環境を作りました。
バージョン
この設定は React2Shell に対して脆弱な状態です。同じ設定にする際はお気をつけて。
- Next.js: 16.0.6
- React: 19.2.0
ソースコード
'use client';
import { updateUserData } from "./actions";
import { useState } from "react";
export default function Home() {
const [name, setName] = useState("Guest");
const [email, setEmail] = useState("anonymous@example.com");
const onSubmit = async () => {
const newName = (document.querySelector('input[name="name"]') as HTMLInputElement).value;
const newEmail = (document.querySelector('input[name="email"]') as HTMLInputElement).value;
const res = await updateUserData(newName, newEmail);
setName(res.name);
setEmail(res.email);
console.log("User data updated on client:", res);
}
return (
<html>
<body>
<div>
<h1>Welcome to the Sample Page</h1>
<h2>{"Your Name: " + name}</h2>
<h2>{"Your Email: " + email}</h2>
<form action={onSubmit}>
<input type="text" name="name" placeholder="Enter your name" defaultValue={name} required />
<input type="email" name="email" placeholder="Enter your email" defaultValue={email} required />
<button type="submit">Update Info</button>
</form>
</div>
</body>
</html>
);
}
'use server';
export async function updateUserData(name: string, email: string) {
console.log("Updating user data:", name, email);
return { name, email }
}
この状態で、下記を確認します(Chrome の開発者モードを利用します)
- クライアント→サーバーとしてどのようなリクエストが送られるか
- サーバー→クライアントのレスポンスはどのようなものか
リクエスト
結構独自 header がありつつも、送られる内容はシンプルでした。Cookie 周りに開発環境がごちゃごちゃになって生まれた関係ないものが混ざってますが、気にしなくてOKです。


気になった点としては、下記でしょう。
- text/x-component のやり取り。ただ、平文として送っているように見えます。
- 下記の独自ヘッダー達
- next-action: どのサーバーアクションを行うべきか。この ID をサーバー/クライアントで共有することで、行うべきアクションを指定することが可能です。
- next-router-state-tree: パフォーマンス向上のため、現在のルーティング状態をサーバーに送信しているようです。参考
partial rendering relies on the next-router-state-tree header from the client to decide if the server should respond with the shared layout data, or only the page data, to avoid over-fetching and improve performance.
- x-nextjs-html-request-id: 謎(気にする必要は無いはずではある)
- x-next-js-request-id: 謎(気にする必要は無いはずではある)
レスポンス
:N1765810143206.2812
0:{"a":"$@1","f":"","b":"development"}
1:D{"time":0.3073199999053031}
1:{"name":"Guest","email":"anonymous@example.com"}
これが噂の Flight Protocol ですね。3 つの chunk からなるように見受けられます。
重要な点として、リクエスト時ではなく、レスポンス時に評価されている気がします?React2Shellって、「リクエスト内容がそのままレスポンスに反映されて、それがエスケープされずに Flight Request として取り扱われてしまった」ことが原因の可能性、あるのかもしれません。
Server Actions のセキュリティについては、下記も参考になりそうです(執筆中に見つけました)
https://note.com/offers_deepdive/n/n7620046e7c62#5b3a1181-a868-426e-a153-464c76c03606
Flight Protocol における "$"
Flight Protocol にはほぼほぼ $ という特殊文字が登場します。下記のような意味を持ちます。
量が非常に多く、生成 AI に作成してもらったものをそのまま掲示します。ただし、内容については大まかに合っていることを確認していて、ざっくりとした理解にご利用いただけると幸いです。
| 表記 | 例 | 意味 |
|---|---|---|
| \$\$ | \$\$hello | エスケープされた通常文字列。で始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なで始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なで始まるリテラル文字列を特殊扱いから逃れる。デシリアライズ時にvalue.slice(1)で余分なを削除し、元の文字列(例: \$hello)を返す。 |
| \$@ | \$@1 | PromiseまたはChunk参照。@以降の16進IDをparseIntで取得し、getChunkでChunkオブジェクトを返す。非同期値のストリーミングをサポート。 |
| \$F | \$Fabc | Server Reference(サーバー関数)。F以降のrefをgetOutlinedModelでメタデータを取得し、loadServerReferenceで関数をロード。サーバーアクションの呼び出しに使用。 |
| \$T | \$T123:0 | Temporary Reference(一時参照)。response._temporaryReferencesからcreateTemporaryReferenceでオブジェクトを作成。referenceが未定義の場合エラー。トランジェントデータを扱う。 |
| \$Q | \$Qdef | Mapオブジェクト。Q以降のrefをgetOutlinedModelで取得し、createMapでMapインスタンスを構築。JSON非対応のコレクションをシリアライズ。 |
| \$W | \$Wxyz | Setオブジェクト。W以降のrefをgetOutlinedModelで取得し、createSetでSetインスタンスを構築。 |
| \$K | \$K456 | FormDataオブジェクト。K以降のstringIdでプレフィックス(prefix + stringId + '')を作成し、_formDataからエントリを収集してFormDataを構築。フォーム/バイナリデータを扱う。 |
| \$i | \$ighi | Iterator。i以降のrefをgetOutlinedModelで取得し、extractIteratorでイテレータを抽出。イテラブルデータをサポート。 |
| \$I | \$I | Infinity値。グローバルInfinityを返す。 |
| \$-0 | \$\$ -0 | 負のゼロ値。value === ' \$\$-0' で-0を返す。特殊数値を区別。 |
| \$- (例: \$-I) | \$\$ -I | -Infinity値。 \$\$-0以外の場合、-Infinityを返す(コードではelseで一律-Infinity)。 |
| \$N | \$N | NaN値。グローバルNaNを返す。 |
| \$u | \$u | undefined値。グローバルundefinedを返す(\$undefinedとしてもマッチ)。欠損値を表現。 |
| \$D | \$D2023-01-01T00:00:00Z | Dateオブジェクト。D以降の文字列をDate.parseでパースし、Dateインスタンスを返す。 |
| \$n | \$n123 | BigInt値。n以降の文字列をBigIntコンストラクタで変換。 |
| \$A | \$A789 | ArrayBuffer。parseTypedArrayでBlobからバッファを作成(バイト長1)。TypedArrayのベースバッファ。 |
| \$O | \$Oabc | Int8Array。parseTypedArrayでInt8Arrayを作成(バイト長1)。 |
| \$o | \$o123 | Uint8Array。parseTypedArrayでUint8Arrayを作成(バイト長1)。 |
| \$U | \$U456 | Uint8ClampedArray。parseTypedArrayでUint8ClampedArrayを作成(バイト長1)。 |
| \$S | \$S789 | Int16Array。parseTypedArrayでInt16Arrayを作成(バイト長2)。 |
| \$s | \$sabc | Uint16Array。parseTypedArrayでUint16Arrayを作成(バイト長2)。 |
| \$L | \$L123 | Int32Array。parseTypedArrayでInt32Arrayを作成(バイト長4)。 |
| \$l | \$l456 | Uint32Array。parseTypedArrayでUint32Arrayを作成(バイト長4)。 |
| \$G | \$G789 | Float32Array。parseTypedArrayでFloat32Arrayを作成(バイト長4)。 |
| \$g | \$g123 | Float64Array。parseTypedArrayでFloat64Arrayを作成(バイト長8)。 |
| \$M | \$M456 | BigInt64Array。parseTypedArrayでBigInt64Arrayを作成(バイト長8)。 |
| \$m | \$m789 | BigUint64Array。parseTypedArrayでBigUint64Arrayを作成(バイト長8)。 |
| \$V | \$Vabc | DataView。parseTypedArrayでDataViewを作成(バイト長1)。バッファビュー。 |
| \$B | \$Babc | Blob参照。B以降の16進IDをparseIntで取得し、_formDataからプレフィックスキー(_prefix + id)でBlobを取得。バイナリデータを扱う。 |
| \$R | \$R123 | ReadableStream(デフォルト)。parseReadableStreamでストリームを作成。ストリーミングデータをサポート。 |
| \$r | \$r456 | ReadableStream(bytes型)。parseReadableStreamでバイトストリームを作成。 |
| \$X | \$X789 | AsyncIterable。parseAsyncIterableで非同期イテラブルを作成(iterator: false)。 |
| \$x | \$xabc | AsyncIterator。parseAsyncIterableで非同期イテレータを作成(iterator: true)。 |
| \$ (デフォルト: id or id:path) | \$1 または \$1:proto:then | 参照ID(Outlined Model)。value.slice(1)をrefとし、getOutlinedModelで解決。単一IDはChunk value、:区切りpathはオブジェクト走査(例: value[path[0]][path[1]]...)。ツリー構造の効率化に使用。 |
多くの関数で getOutlinedModel という関数を呼び出しており、他の chunk の結果を参照しながら、それを Map や Set、一時参照などに変換する、という感じがしています。ただ、ちょっとソースコードの読み込みが甘くて、$Q123:123 といった値でどのような挙動をするかは未検証です。
また、parseTypedArray は _formData.get(_prefix + <ref: $Rxxxのxxxの部分>) を指定した型に変換します。
parsedReadableStream/parseAsyncIterable に関してはちょっと謎めいた挙動をしているので、分かり次第追記するかもです(どちらも似た挙動ではあるので、しっかり追えれば理解できそう)
Flight Request の挙動を手でシミュレーションしてみる
ちょうど先ほど好例があったので、試してみましょう。
:N1765810143206.2812
0:{"a":"$@1","f":"","b":"development"}
1:D{"time":0.3073199999053031}
1:{"name":"Guest","email":"anonymous@example.com"}
最初にキーが 0 のオブジェクトが解釈されます。
0:{"a":"$@1","f":"","b":"development"}
ただ、$@1 が含まれており、評価しきれません。ちょっと置いておきましょう。
次に 1 のオブジェクトを評価します。
1:{"name":"Guest","email":"anonymous@example.com"}
これは評価可能です。resolve されるタイミングで、0 のオブジェクトの評価が遅延処理で返ってきます。
0:{"a":{"name":"Guest","email":"anonymous@example.com"},"f":"","b":"development"}
いい感じになりました。残った下記ですが、恐らく chunk の評価時間だと思われます。(若干調査中。もし違ったら変更します)
1:D{"time":0.3073199999053031}