カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Next.jsのコンパイラから知るServer Actionsの完全解析 ~セキュリティ上の注意点も含めて~

はじめに

StatHackカンパニーの渡邉です。 私の普段の取り組みをこちらで紹介しているのでこちらもどうぞ。

note.kaminashi.jp

私たちKaminashiでは、さまざまなプロダクトにNext.jsを採用し始めています。

今回のブログではNext.jsの最も特徴的な機能の一つであるServer Actionsに関してフォーカスし、それがどういう仕組みで動いているのかコンパイラのソースコードを確認しながら解説し、 最後に実装上の注意点について述べます。

特にServer Actionsの具体的な中身の解説に関してはヘビーなので、実装上の注意点だけ見てもらうだけでも良いかもしれないです。

それではやっていきましょう。

Server Actionsとは?

Server ActionsとはNext.js v14から正式にリリースされた機能で、従来フロントエンドのために記述していたReactの中にサーバ (Rest APIなど) が持つべき機能を記述できるようになった機能です。

従来のアーキテクチャとの違いを含めてみていきましょう。

Server Actions以前のアーキテクチャ

従来はReactなどで記述されたフロントエンドとは完全に分離した、サーバ (Rest APIなど) を立ち上げていました。

DBへのアクセスや認証系の処理、サーバの持つ秘密情報へアクセスするのはサーバが処理し、

その結果をレスポンスし、フロントエンドが色付けをするというのが流れでした。

これによって、管理するべきサービスがフロントエンドとRest APIの二つになっていました。

下に、Reactでフロントエンドを、ExpressでAPIを記述し、フロントエンドのボタンを押したら、ブラウザのfetch APIでReactからExpressに問い合わせるコードを示しています。

Server Actions以降

次にServer Actions以降ではどうなったのかみてみましょう。

下の図に示す通り、サーバの処理も、クライアントの処理も全て一つのNext.jsに記述できます。

use client とファイルの先頭に記述すれば、そのファイルはクライアント(ブラウザ)で、use server とかけば、そのファイルはサーバ上で実行されるように、Next.jsがコンパイルします。

これには非常に大きな利点があります。

  • 関数呼び出しだけサーバとの通信も完結する、圧倒的記述量の少なさ
  • API・フロント開発をまとめてできちゃう
  • APIとフロントエンドの配信を一つのサービスで運用できる

ただし、リリースされた当時も現在も、これには多くの賛否が巻き起こっています。

上のメリットもありますが、SQLのクエリもReactの関数の中に記述できる特性上、クライアントのコンポーネントがDBのパスワードなどの秘密情報にアクセスしやすくなり漏洩したり、さまざまなリスクが介在しかねないということです。

ただし、これらのリスクはNext.jsがコンパイル時にさまざまなテクニックで守られていて、それらについては詳細を後述していきます。

ここまでで「Server Actionsすげ〜」となったでしょうか?

Server Actionsの実態

Server Actionsは、クライアントとサーバのやり取りをコーディング上、楽にしたものです。

ただし、コード上は非常に簡潔に書けて美しいのですが、実態は従来のアーキテクチャで示した、FetchとRest APIの仕組みと全く変わらず、それを隠蔽しているだけなのです。

実際にどんなリクエストが飛んでいるのか、ブラウザのネットワークタブやcURLを活用して、確認していきます。

以下にとてもシンプルなコードを書いてみます。

ユーザがWebページから送信ボタンを押すと、Server Actionsである sendDate 関数が呼ばれて、サーバが文字列を返すというものです。

"use client";

import { sendDate } from "./server";

export default function Page() {
  return (
    <>
      <button
        onClick={() => sendDate(new Date())}
      >送信</button>
    </>
  )
}
"use server";

// サーバーサイドでの処理
export const sendDate = async (date: Date) => {
  console.log("Received date:", date.toISOString());
  return `Hello, World! ${date.toLocaleString()}`;
}

以上の実装で、 sendDate 関数はクライアントからDateオブジェクトを受け取り、サーバ上で実行される関数になります。

ここでブラウザのネットワークタブを見ながら、送信ボタンを押すと、何やらPOSTリクエストが走っています。

そうなのです。結局Server Actionsと言いつつ、Next.jsをBuildする際に、よしなにRest APIの実装を行っていて、それを呼び出すfetchを隠蔽してくれていて、実態は従来のアーキテクチャで示したものと変わらないのです。

実際にこのリクエストは cURL でも実行することができちゃいます。

リクエストを抽出してterminalに貼り付けました。

--data-raw に Server Actionsの引数が与えられ、

レスポンスには何やらクライアントで処理されるためのデータが返ってきています。

すなわち、Server Actionsとして定義した関数が勝手にRest APIとして定義され、レスポンスを返すRest APIとなりました。

Server Actionsではブラウザ以外からもリクエストを飛ばすことは可能であり、入力値にはどんな情報を入れることも可能であるので、実装時に注意しましょう。

これらの注意事項は最後の方のセクションでまとめていきます。

ソースコードやBuildファイルから読み解くServer Actionsの仕組み

Next.jsのコンパイラはRustで書かれていて、コードはOSSで公開されています。

Next.jsコンパイラ | Next.js 日本語ドキュメント

next.js/crates at canary · vercel/next.js

今回の記事では、Server Actionsにフォーカスしてソースコードや、コンパイラが生成したコードからServer Actionsがどう動くか詳細に説明していきます。

ひとまず全体像を説明します。

Server Actionsの全体像

Server Actionsに関して、Next.jsのコンパイル時と実行時の関係を上の図に示します。

Next.jsをコンパイル時にServer Actionsになりうる関数を特定し、それぞれの関数にユニークなActionIDを生成して割り当てます。

Build時に様々なファイルが生成され、その中のクライアントが実行するJSファイルにはイベントハンドラごとにどのServer Actionsを実行するかをハードコードし、サーバが実行するJSファイルにはActionIDごとの実行関数が記述されます。

Next.jsの実行時にはクライアントは呼び出されたイベントに対応するActionIDを割り出し、Serverに ActionId関数への入力 を含めてPOSTリクエストを送り、レスポンスが返ります。

個別にそれぞれ確認していきます。

Action IDの生成

Next.jsのコンパイラがServer Actionsになる関数を特定し、特定された関数の一覧は Build時に生成される server-reference-manifest.json から確認することができます。

それぞれの関数ごとに 7f12f53db85b42fbbeec113fc3ab5ff5bad0cdcd01 のようにActionIDが振られており、それがどのページから呼び出されるものであるのかや、async関数なのかどうかなどが記述されています。

{
  "node": {
    "7f12f53db85b42fbbeec113fc3ab5ff5bad0cdcd01": {
      "workers": {
        "app/page": {
          "moduleId": "472",
          "async": false
        }
      },
      "layer": {
        "app/page": "action-browser"
      }
    },
    "7f6e9a1ca1d21054f2a81d0f8dfddb31152b586e90": {
      "workers": {
        "app/page": {
          "moduleId": "472",
          "async": false
        }
      },
      "layer": {
        "app/page": "action-browser"
      }
    }
  },
  "edge": {},
  "encryptionKey": "Ix4o9HMYnTLmJAj9FdJLIxo3dGQEqFSyR6bBO/lZm3Y="
}

このIDがどう割り振られているか、コンパイラから確認してみましょう。

以下はコンパイラのコードを一部抜粋したものです。

コンパイラ固有のsaltServer Actionsが定義されたファイル名exportされた関数の名前 などを結合して、SHA1のハッシュ関数へ渡してユニークなIDを生成しています。

    fn generate_server_reference_id(
        &self,
        export_name: &str,
        is_cache: bool,
        params: Option<&Vec<Param>>,
    ) -> Atom {
        // Attach a checksum to the action using sha1:
        // $$id = special_byte + sha1('hash_salt' + 'file_name' + ':' + 'export_name');
        // Currently encoded as hex.

        let mut hasher = Sha1::new();
        hasher.update(self.config.hash_salt.as_bytes());
        hasher.update(self.file_name.as_bytes());
        hasher.update(b":");
        hasher.update(export_name.as_bytes());
        let mut result = hasher.finalize().to_vec();

next.js/crates/next-custom-transforms/src/transforms/server_actions.rs at de7adc2425e07441de8b5ce85cc6e70c634358ef · vercel/next.js

このIDがクライアント用のJSファイルとサーバ用のJSファイルにハードコードされるわけです。

クライアントが実行するAction IDの特定

Next.jsの実行時にユーザの入力や操作に基づいてServer Actionsを実行する際に、クライアント側で、何のServer Actionsを実行するのか知る必要があります。

この際に以下のような sendFormData というSever Actionsの関数を想定してどのように特定するか説明します。

"use client";
import { sendFormData } from "./server";

export default function Page() {
  return (
    <>
      <form action={sendFormData}>
        <input type="text" name="name" placeholder="名前" required />
        <input type="number" name="age" placeholder="年齢" required />
        <button type="submit">送信</button>
      </form>
    </>
  )
}
"use server";

export const sendFormData = async (formData: FormData): Promise<void> => {
  // フォームデータを受け取る
  const name = formData.get("name");
  const age = formData.get("age");
  console.log("Received form data:", { name, age });
}

この場合はクライアントが受け取るHTMLに、 $ACTION_ID_7f12f53db85b42fbbeec113fc3ab5ff5bad0cdcd01 のような形でAction IDを指定して記述されています。

これによってクライアントはこのFormがSubmitされる際に、どのServer Actionを実行すれば良いのか分かるわけです。

余談として、Formの際はHTMLファイルに隠されたinputタグとして記述され、ButtonのonClickのイベントなどではJSファイルにActionIDがハードコードされています。

Server Actionsへのリクエスト

実際に上のフォームでPOST リクエストされる際のリクエストボディは以下のようになっています。

このように、どのActionIDに対して、何の入力を入れるのかをリクエストに含めています。

Formの場合は全部文字列であるので、このようにシンプルな文字列を送るのですが、Server Actionsの引数にDateオブジェクトなど特定のObjectの場合は、リクエストの文字列の先頭に $D をつけてキャストしていました。

Dateだけでなく、SetやMapなどにもそれぞれ異なるキャストがつけられたデータがServer Actionsに渡されます。

以上がServer Actionsの基本的なリクエストの仕方でした。

Closure変数の取り扱いについて

最後に以下の箇所で指摘されているClosure変数というものが絡むとServer Actionsが面白い挙動をするので、それに関してまとめます。

少し難しいので読み飛ばしてもらっても構いません。

Guides: Data Security

クロージャとは

JavaScriptにおけるクロージャは、内側の関数が外側の関数の変数を「記憶」できる仕組みです。

Closures - JavaScript | MDN

function outerFunction() {
  const secretValue = "これは外側の変数";

  function innerFunction() {
    console.log(secretValue);  // 外側の変数にアクセスできる
  }

  return innerFunction;
}

const innerFunction = outerFunction();
innerFunction();  // "これは外側の変数" と出力される

重要なポイントとして、内側の関数は、外側の関数が終了した後でも、外側の変数にアクセスできる点が挙げられます。

そして、この「変数を記憶している関数」がクロージャです。

Server Actionsにおけるクロージャの暗号化

Next.jsは、Server Action内でクロージャ変数を使用する場合、セキュリティ対策として自動的に暗号化を行います。

クロージャを含むServer Actionの実装例

export default function ClosurePage() {
  const secret = 'my-secret-key';  // クロージャ変数

  async function encryptedAction() {
    "use server";
    // secretがクロージャでキャプチャされる
    console.log('Secret accessed:', secret);
    return "Secret Accessed!";
  }

  return <ClientComponent serverAction={encryptedAction} />;
}

このコンポーネントが encryptedAction を実行する際に、リクエストボディーの一部が暗号化されています(….!)

今まではネットワークタブを見たらServer Actionsに渡されるデータがそのまま見れていました。

しかし、このクロージャを含む場合に、フォームデータが暗号化されています。

この暗号文には何が含まれているのか気になりますよね。

この暗号文はAESという共通鍵暗号方式を活用して暗号化されているので、復号してみましょう。 以下にそのスクリプトを書いたので主要な箇所を抜粋して以下に示し、暗号文の復号結果を見てみます。

ちなみにこの秘密鍵は先ほど述べた server-reference-manifest.json に記述されています。

// AESの複合処理
function decryptServerAction(encryptedData, encryptionKey) {
    const encrypted = Buffer.from(encryptedData, 'base64');
    const key = Buffer.from(encryptionKey, 'base64');

    // AES-GCM構造を分解
    const iv = encrypted.slice(0, 16);           // 初期化ベクトル
    const ciphertext = encrypted.slice(16, -16); // 暗号文
    const authTag = encrypted.slice(-16);        // 認証タグ

    // AES-256-GCM復号化
    const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
    decipher.setAuthTag(authTag);

    let decrypted = decipher.update(ciphertext, null, 'utf8');
    decrypted += decipher.final('utf8');

    return decrypted; // 形式: "actionId:[クロージャ変数の配列]"
}

以上の関数で復号するすると以下の結果を得られました。

復号化結果: 700df8bdd5cb7897e01e3c286139a6855e312f1ed9:["my-secret-key"]

これはActionIDがキーとなり、クロージャ変数がValueとなっていました。

700df8bdd5cb7897e01e3c286139a6855e312f1ed9:["my-secret-key"]
└─ Action ID ─────────────────────────────┘ └─ クロージャ変数 ─┘

この ActionIDとクロージャ変数の暗号文がクライアントに渡されているのですが、なぜ必要になるのでしょうか?

クロージャの変数を暗号化してクライアントに渡す必要性

先ほどのコードを再掲します。

export default function ClosurePage() {
  const secret = 'my-secret-key';  // クロージャ変数

  async function encryptedAction() {
    "use server";
    // secretがクロージャでキャプチャされる
    console.log('Secret accessed:', secret);
    return "Secret Accessed!";
  }

  return <ClientComponent serverAction={encryptedAction} />;
}

ここでこの secret という変数は、コンポーネントがレンダリングされた際に必要となる変数です。

たとえば secret に定数ではなく、ユーザが住む地域の天気が代入される場合は、この値はクライアントごとに動的に変わる値になります。

export default function ClosurePage() {
  const weather = await getWeather(userLocation); 

  async function encryptedAction() {
    "use server";
    // secretがクロージャでキャプチャされる
    console.log('Secret accessed:', weather);
    return ;
  }

  return <ClientComponent serverAction={encryptedAction} />;
}

そのような場合、Server Actionを通してクライアントとサーバがやり取り際に、このクロージャ変数をどこかに記憶していないといけません。

ここで、サーバがその変数を一時的に記憶することもできると思うのですが、サーバでこの変数を記録するのではなく、クライアントにクロージャ変数の暗号文を送り、クライアントから毎回サーバに渡すことで、サーバが記憶する変数を減らし、メモリを削減しているのだと思います。

これによりクロージャ変数を暗号文でクライアントに送ることで管理していました。

クロージャ変数には、サーバの秘密情報を入れることも多いので、私はこの秘密の値を暗号化したとしてもクライアントに送ることに少し違和感を感じました。

補足としてサーバ上にある秘密鍵が漏洩した場合に、これらのクロージャ変数が漏洩することになるのでこの秘密鍵の管理には注意が必要になります。

Server Actionsならではの注意点

Kaminashiではプロダクトをリリースするまえに、セキュリティエンジニアと数時間にわたって、Production Readiness Checkということをします。

これはプロダクトが満たすべきセキュリティ要件を話しながら確認するというものです。

kaminashi-developer.hatenablog.jp

今回はその項目を確認していた際に、Server Actionsならではで注意するべきことがあると感じたことを、実際のソースコードを含めながら解説していきます。

サーバが管理するべき秘密情報を意識する

サーバが環境変数や特定のファイルにDBのパスワードなどの秘密情報を持っていることはよくあると思います。

たとえば以下のServer Actionsを生成してしまったとしましょう。

export async function pathTraversalAndBadResponse(date: Date, filePath: string) {
  // 任意のファイルを読み込めてしまう(パス・トラバーサル脆弱性)
  const data = await fs.readFile(filePath, 'utf-8');
  // 環境変数も丸ごと返却(秘密情報漏洩)
  const secrets = JSON.stringify(process.env);
  return `Hello, World! ${date.toLocaleString()}\n\nFile Content:\n${data}\n\nEnv:\n${secrets}`;
}

API を意識しなくなり、関数として記述できるようになった結果、クライアントは Server Actions に引数を渡すだけで安全だと錯覚しがちですが、この関数は実質的に API として公開されています。

filePath を引数に取ることで一見便利に見えますが、cURL などを用いれば任意のパスへリクエストを送ることができるため、パストラバーサル攻撃が可能になります。

また、環境変数を戻り値に入れてしまうことで、これはサーバとクライアントの間の通信で中身が漏れます。

なので、ちゃんとこれらの関数はAPIとして公開され、リクエストはなんでも入り得るし、秘密情報をresponseに含めないように意識しましょう。

ちゃんとFetchが実行されることを意識しよう

Server Actionsは開発者の体験としては関数が呼ばれるだけです。

なので、一般的な関数呼び出しと錯覚してしまいがちですが、実態としてはクライアントとサーバでの通信が発生します。

なので、Server Actionsで公開される関数の引数に巨大なObjectが来る場合は、それがネットワークに流れるわけだし、Server Actionsの実行に非常に時間がかかる場合は、UXを損なうわけです。

以下にServer Actionsの例を記述します。

"use server";

export async function heavyInput(base64Image: string) {
  // base64 をバッファに変換(巨大な入力でメモリ枯渇の恐れ)
  const img = Buffer.from(base64Image, "base64");
  await fs.writeFile(`/tmp/${Date.now()}.png`, img);
  return `Received ${img.length} bytes and saved as PNG`;
}

export async function longTimeAction() {
  // 長時間実行されるアクション(タイムアウトの可能性)
  await new Promise(resolve => setTimeout(resolve, 10000)); // 10秒待機
  return "This action took a long time to complete.";
}

一つ目の関数は引数に画像を入れているのですが、この画像のデータはネットワークを流れて実行されます。なので、Server Actionsの引数に巨大なObjectが来る場合は意識をする必要があります。

二つ目の関数は実行時間が長いものです。クライアントからServer Actionsを実行する際には、これらの特性を考慮してUXを設計する必要があります。長時間実行される処理の場合は、進捗状況の表示や非同期処理の分割などの対策が必要です。

ここら辺はAPIを分離していたら明確にリクエストレスポンスを意識しやすいのですが、Server Actionsは関数にすればなんでもAPIに切り出せやすすぎる特性上意識が薄くなりがちです。

余談ですが、Server Actionsのデフォルトで、入力のサイズに制限があります。

デフォルトでは1mbの入力しか受け付けず、以下のパラメータを Next.jsの設定ファイルから調整することでサイズの最大値を調整することができます。

next.config.js: serverActions | Next.js

サーバ側でも絶対にバリデーションしよう

サーバ側でも入力にもちゃんとバリデーションしましょう。

"use server";
import { z } from "zod";

export async function validateString(formData: FormData) {
  const input = formData.get("input");
  z.string().length(32).parse(input);
  return `入力を受け付けました: ${input}`;
}

上にzodでformの入力長をvalidationしている例を示しています。

前節で述べた通り、入力のデータサイズには上限はありますが、実際の中身がどういうものなのか、長さは大丈夫かなどを確認する必要があります。

Server ActionsはcURL等でも呼び出せるので、実質どんな入力でもサーバにリクエストすることが可能です。

クライアントとサーバのコードが近くに凝集されているせいで、クライアントでバリデーションされて安心になったつもりが、 サーバ側でもバリデーションしないと、予期せぬデータが入力される場合があるので注意が必要です。

最後に

Server Actionsに関して理解が深まったでしょうか?

Server Actionsは、賛否が分かれる機能であるのですが、慣れると大変便利であると個人的に感じています。

このブログをきっかけに少しでも理解に助けになれば幸いです。

最後に、Kaminashiではエンジニアの採用を募集中です! 下に記載されるリンクをご確認ください。