🎃

Server Actionsにユーザ操作されたくないデータは渡さない

2024/02/26に公開

Next.jsやServer Actionsに限らずWebアプリケーション一般的な話になりますが、機能開発する際に、ユーザに勝手に操作されたくないデータを <input> タグを通じて受け取ることは避けなければなりません。

例えば、極端な例ですがECサイトで商品の価格を <input type="hidden" name="price" value="500" /> と書いてあった場合、ユーザが勝手にHTMLの値を書き換えてしまうと意図せず安い価格で購入できてしまいます。このように信頼できないデータを使って処理をしてしまうと困ります。この例の場合、渡すのは商品IDであり、価格はサーバ側でDBから取得した信頼できる値を採用するべきです。

と、書くとそんなこと当然だと感じるのですがNext.js App Routerから導入されたServer Actionsを使っていると、サーバ側とブラウザ側どちらで処理されているのか境界線が曖昧になってこんなコードを書いてしまう可能性があります。

駄目なパターン例

以下の3つのパターンで書いていますが、どれも信頼できない値がサーバ側にわたってくる悪い例です。

bind を使っているパターン

このコンポーネントはログインしている自分自身のユーザ名を変更するフォームです。こんなコード書かないと思うような極端な例ですが、わかりやすくするためご容赦ください。

この例はReactの bind を利用してServer Actionsの updateUser 関数に、ユーザIDを一緒に渡すようにしています。この時点でなんだか嫌な予感がしますね。

import { updateUser } from "./updateUser";

export function Form({ userId }: { userId: string }) {
  const updateUserWithId = updateUser.bind(null, userId);
  return (
    <form action={updateUserWithId}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  );
}

これは一見、 <input type="hidden"> を書いていないのでユーザIDはブラウザに露出していないと思ってしまいますが、 bind<input type="hidden" name="userId" value="100" /> と同等のタグを生成します。

ただしこの <input type="hidden"> はProgressive Enhancement用のタグなので、JSが有効な環境では、値を書き換えてサブミットしてもServer Actionsに送信される値は元の値のままです。JSをオフにすると、はじめて書き換えが有効になります。

とはいえcurlなどでPOSTする値自体を変更して直接リクエストすることができるため、信頼できない値であることには代わりありません。

hidden を明示的に利用しているパターン

これは明らかに駄目なパターンですね。直接HTMLを書き換えてPOSTできてしまいます。

import { updateUser } from "./updateUser";

export function Form({ userId }: { userId: string }) {
  return (
    <form action={updateUser}>
      <input type="hidden" name="userId" value="{userId}" />
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  );
}

FormDataと別にデータを渡すパターン

Server Actionsの関数にはFormDataとは別の値を渡すこともできるため、このようにしてユーザIDを渡すことができます。

import { updateUser } from "./updateUser";

export function Form({ userId }: { userId: string }) {
  const handleSubmit = async(formData: FormData) => {
    await updateUser({formData, userId});
  };
  return (
    <form action={handleSubmit}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  );
}

これは <input type="hidden"> は出力されませんが、ユーザIDをFormDataと一緒にPOSTするため、JSファイルを修正もしくは、curlなどで書き換えた値を直接POSTできてしまいます。bind のパターンと同じですね。

どう書いたら良いか?

Server Actionsをコンポーネント内部で定義するパターン

Server Actionsは別ファイルに定義せず、利用したいコンポーネント内部でも定義することができます。この場合、Next.jsではServer Actions関数を暗号化します。つまりユーザID自体も暗号化されます。

export function Form({ userId }: { userId: string }) {
  async function updateUser(formData: FormData) {
    "use server";
    await new Promise((resolve) => setTimeout(resolve, 1000));
    console.log(userId, formData);
    return { success: true };
  }

  return (
    <form action={updateUser}>
      <input type="text" name="name" />
      <button type="submit">Update User Name</button>
    </form>
  );
}

これであればユーザが値を書き換えて送信することは難しくなります。ただ、難しくなっているだけで行儀がいい訳ではないと思います。(あと可読性が低くなる気がするので別ファイルに分離したい気持ち)

素直にサーバ側で取得する

今回の例だと自分自身のユーザ名を変更したいので、セッションなどでユーザ情報を持っているはずです。以下のようにセッションからユーザIDを特定するという普通のWebアプリケーションの作り方をすると良いでしょう。

export function updateUser(formData) {
  const session = await getSession();
  const userId = session.userId;
  // ...
}

まぁ、こんなケースはあまり発生しないと思いますが、React Server Componentでかつ、明示的にhiddenを書いていないので、ブラウザに露出していないと勘違いしてしまいがちかなと思い注意喚起として書きました。もし間違いがあればコメントで教えて下さい🙏🏻

ムーザルちゃんねる

Discussion