🔭

Astroでreact2shellのような脆弱性が起きない理由

に公開1

2025年12月3日に公開されたサーバー側での任意コード実行が可能となるReact及びNext.jsにおける脆弱性CVE-2025-55182。通称react2shell。この脆弱性は単なる実装バグとしては片付けられない、フロントエンド界隈全体に及ぶ議論を巻き起こしました。

react2shellはなぜ起こるの?

RSCが有効になっているサーバーに細工されたリクエストを送ることで、サーバー側で攻撃者の指定する任意のコードを実行することができます。具体的にはReactのFlightプロトコルに関数の参照やプロトタイプ汚染などを仕組むことで、認証の有無に関係なく攻撃が可能となります。

この攻撃の流れを観察すると2つの問題点が見えてきます。

React Flight プロトコルの実装上のバグ

任意のオブジェクト.constructor.constructorでeval、Thenableの悪用、prototype汚染など、JavaScriptという言語自体の穴を悪用できる状態になっていました。本来このような脆弱性が起こらないように慎重に実装するべきでしたが、対応は不十分でした。

ユーザーからの入力の信頼しすぎ

本来ユーザーから送信されたデータは無責任に信頼してはいけません。しかし、今回の場合では受け取ったデータが想定した構造であるかを十分に検証せずに、正当なリクエストであるとみなして処理していました。


この問題から派生して、「そもそもこの仕組みはクライアントとサーバーは密結合しすぎである」とアーキテクチャ自体を批判する意見もあります。

RSFは境界を曖昧にする過度な抽象化なのか?

RSFはファイルの先頭、またはコンポーネント内で"use server"と書くだけでAPIエンドポイントを作れる機能です。しかし、同じファイル内にサーバー側で実行されるコードとクライアント側で実行されるコードが混在するのはとても混乱を招きやすい状況です。別ファイルに分けたとしても、ファイルが別のファイルと複雑な依存関係を持っているとやはり混乱を招いてしまいます。

例) あるコンポーネントを含むファイルAが"use server"指定のあるファイルBに依存しています。しかし、そのファイルも別の関数を含むファイルCに依存しています。この場合、ファイルCに"use server"はつけるべきでしょうか? → 結論としては、付ける必要はありません。なぜなら"use server"は「ファイルがサーバーで実行される」ことを示すマーカーではないからです。

それに加えて、"use server"という文字列(厳密にはdirective)を置くという小さな記述によって、成果物が大きく変わる点は、直感に反すると言えるでしょう。

今までの話をまとめると、RSFは大まかに以下の弱点を持つことになります。

  1. 独自プロトコルによる実装上のバグ
  2. ユーザーからのリクエストを信用して十分に検証していない
  3. クライアントとサーバーの境界が曖昧になる

種々の問題を解決する手段としては革新的ではあるものの、いざ使うとなると複雑さが垣間見えてしまいます。


さて、所変わってSSGのAstroの話...

Astroの場合

同じ課題(より簡単なフォーム送信やデータ処理)を解決するための手段としてAstro Server Actionsというものがあります。ここで、Astroがどのようにこの課題にアプローチしているかを見てみましょう。
https://docs.astro.build/en/guides/actions/

Astro Server Actionsとは?

Astroコンポーネントからサーバー側で行う処理を簡単に実装できる機能です。

大まかにAstro Server Actionsの流れを解説します。

src/actions/index.tsというファイルにサーバー側で実行するactionを定義します。入力を受け取る場合、その入力のスキーマを指定しておく必要があります。

src/actions/index.ts
import { defineAction } from "astro:actions";
import { z } from "astro/zod";

export const server = {
  // defineActionという専用の関数を使用
  getGreeting: defineAction({
    // スキーマ定義が強制される
    input: z.object({
      name: z.string(),
    }),
    handler: async (input) => {
      return `Hello, ${input.name}!`
    }
  })
}

そして、任意のコンポーネント内で処理を呼び出します。actions.アクション名という形式で呼び出すことができます。

src/pages/index.astro
---
---
<button>Get greeting</button>

<script>
import { actions } from 'astro:actions';

const button = document.querySelector('button');
button?.addEventListener('click', async () => {
  // ここでの`actions`は src/actions/index.tsからexportされた`server`オブジェクトを参照している
  const { data, error } = await actions.getGreeting({ name: "Houston" });
  if (!error) alert(data);
})
</script>

[1]

actionsを利用する際、エディタの補完機能が働きます。

そして、ボタンを押したとき、以下のようなHTTPリクエストが実行されます。

Request
POST /_actions/getGreeting/ HTTP/1.1
Content-Length: 19
Content-Type: application/json
Host: localhost:4321

{
    "name": "Houston"
}
Response
HTTP/1.1 200 OK
Content-Type: application/json+devalue
Transfer-Encoding: chunked
Vary: Origin

[
    "Hello, Houston!"
]

以上がAstro Server Actionsの概要で、非常に明快な仕様となっています。

では、RSCと比較してみましょう。
前述のRSCの3つの弱点と照らし合わせます。

RSCと比較

独自プロトコルによる実装上のバグ

RSCではFlightプロトコルが脆弱性の要因となりましたが、Astroではユーザーからの入力にはJSON(application/json)を使っています。これにより、チャンク参照によるガジェットチェーン(オブジェクトの内部状態や参照を連結させ、メソッドの呼び出し順序を操作することで、攻撃者が任意のコードを実行できてしまう脆弱性)は起こらず、プロトタイプ汚染のリスクも大幅に低減されます。

ユーザーからのリクエストを信用して十分に検証していない

RSCは複雑な形式のリクエストを十分に検証せずそのまま処理して、脆弱性の原因となっていました。AstroではZodによる検証がサーバー上で必ず実行されます。入力の定義には「関数の引数」ではなく「Zodスキーマ」を使います。

また、Zodを使うことでプロトタイプ汚染も防ぐことができます。Zodではスキーマにないプロパティがあっても、パースした結果には含まれず、その後のプログラムに紛れ込む心配もありません。

クライアントとサーバーの境界が曖昧になる

RSFでは、"use server"というdirectiveを使うことで、同じファイル内にクライアントとサーバーのコードが混在してしまい、境界が曖昧になってしまいます。Astro Server Actionsでは、サーバー側のコードはsrc/actionsディレクトリ内に集約されます。

.
├── astro.config.mjs
├── package.json
├── pnpm-lock.yaml
└── src
    ├── actions        # このディレクトリにサーバー側のコードを集約する
    │   ├── index.ts    
    │   └── user.ts    
    ├── components
    │   └── ...
    └── pages
        ├── index.astro
        └── ...

また、Astro Server Actionsでは、サーバー側のコードを呼び出すときにactions.getMessageactions.user.loginという形式で呼び出します。この形式はAPIエンドポイントを叩く感覚に近く、より直感的な形式となっています。

つまりはRPC

Astro Server ActionsもRSFも、フロントエンドとバックエンドの間で関数呼び出しのような形で通信を行うRPC(Remote Procedure Call)の一種です。しかし、Astro Server ActionsはRPCの実装において、セキュリティと可読性を重視した設計となっています。というのも、Astro Server ActionsはRSFに比べてより後発の技術であり、RSFの弱点を踏まえた上で設計されているからです。[2]

RSFがバンドラを駆使した複雑なRPCであるのに対して、Astro Server ActionsはJSONベースのシンプルなREST APIとして設計されたRPCと言えるでしょう。RSFが「魔法のように見える」RPCを提供する一方、Astro Server Actionsは「ボイラープレートとなるコードを省略する」RPCを提供しています。

また、RSFがREST APIを直接のサーバー関数呼び出しとして隠蔽する一方、Astro Server Actionsはactions.アクション名というREST APIエンドポイントに一対一対応した形式として隠蔽します。

このようなAPIはHono RPCのような他のフレームワークでも採用されている設計ですね。

まとめ

今回の脆弱性、react2shellは、RSFにおける入力信頼と境界設計の弱さを露呈させました。一方、AstroはJSONというシンプルなフォーマット、スキーマ検証の強制、サーバーコードの分離によって、同じ課題を別の方法で解決しています。

Special Thanks

このポストがきっかけとなって本記事ができました。Thanks!!

https://zenn.dev/connect0459/scraps/daff522bc625a5

またこちらの記事も一部参考にさせていただきました。

脚注
  1. Houstonとは、Astro公式マスコット(?)の名前です。kawaii ↩︎

  2. https://github.com/withastro/roadmap/issues/898#:~:text=So far%2C Astro,actions%2C and more. ↩︎

Discussion

odanodan

こちらの記事について Twitter での言及当初から公開されることを楽しみにしていて、たいへん興味深く読ませていただきました。

1点質問があります。

自分はこの記事を「Astro は Server Actions の呼び出しに JSON を使っていて zod で validation しているから安全」という意味で読み取りました。
また自分は React2Shell は Flight プロトコルを実行するときに RCE に繋がるバグがあったと理解しています。
zod による validation の話はユーザーの入力値の検証 (フレームワークの使い方) であるのに対して、React2Shell は Flight プロトコルの実装の考慮漏れ (フレームワーク内部の実装ミス) が原因なので、議論のレイヤーが噛み合っていないと感じています。

具体的には Server Actions の input を zod で validation する話は、RSC だと Server Function の引数を validation する話に対応していると思います。
今回の脆弱性は引数の validation が不足していたのが原因ではないので、議論が噛み合っていないと感じた次第です。
RCE に繋がる Flight プロトコルの payload は、 Flight プロトコルとしては valid なものであったはずなので、zod の validation があっても防げないと考えています。

こちらの解釈だとタイトルと記事の内容が一致しないと思うのですが、なにか解釈が誤っているところがあれば教えていただきたいです。