フューチャー技術ブログ

Next.jsのServer Actionsは、サーバー側のバリデーションは不要なのか?

Next.jsの新機能でTwitter(X)上でも少しバズったのがServer Actionsです。クライアントコンポーネント上にサーバー上で行うロジックを直接書き込むことが可能です。しかし、今までサーバーAPIを実装したことがあるのであれば、サーバー上のロジックであれば何かしらの認証のチェックやらCSRF対策などが必要なのではないか?という疑問を持つはずです。公式ドキュメントを見ても、ブログなどを検索してもそのあたりの話が出てこなかったので、少し動かしてみて検証してみました。

Server Actionsとは

Server Actions<form>タグのactionに設定する特殊なイベントハンドラです。2種類の書き方があります。1つはサーバーコンポーネントの定義の中に書いてしまう方法です。

"use server"

export default function Home() {
// FormDataを受け取るasync関数を定義すると、これがServer Actionsになる
async function onAction(formData: FormData) {
"use server"
// ここのコードはサーバーの中で実行される
}
return (
<main>
{/* Server Actionsの関数は*/}
<form action={onAction}>
<button type="submit">実行</button>
</form>
</main>
)
}

もう1つは関数だけを作成する方法です。このファイルだけ"use server"がついていますが、この関数はクライアントコンポーネントでインポートして利用できます。

"use server"

export async function onAction(formData: FormData) {
// ここのコードはサーバーの中で実行される
}

通常、サーバー側でロジックを動かす場合、ウェブサーバーのルーターにエンドポイント(ハンドラ)を追加し、レスポンスを受け取ってパースしてそれを使って動かすといったボイラープレートを実装する必要があります。外部システムからのリクエストであるため、CSRFトークンをフォームに設定したり、バリデーションなども実装する必要があります。ですが、Server Actionsではエンドポイントを追加したりといった手間はありません。

どのような通信が起きているのか確認する

実際にどのような通信が起きているのか見てみましょう。通常のフォーム送信と比較するために2つフォームを作成しています。

src/app/page.tsx
export default function Home() {
async function onAction(formData: FormData) {
"use server"
for (const key of formData.keys()) {
console.log(`${key}: ${formData.get(key)}`)
}
}
return (
<main>
{/*Server Actions*/}
<form action={onAction}>
<label htmlFor="name">name: </label><input id="name" name="name" /><br />
<label htmlFor="email">email: </label><input id="email" name="email" /><br />
<button type="submit">Server Action</button><br />
</form>
{/*通常のPOST*/}
<form action="/api/action" method="POST">
<label htmlFor="name">name: </label><input id="name" name="name" /><br />
<label htmlFor="email">email: </label><input id="email" name="email" /><br />
<button type="submit">/api/action</button>
</form>
</main>
)
}

通常のPOST処理ではエンドポイントが必要になるため、これも実装しておきます。

src/app/api/action/route.ts
export async function POST(req: Request) {
const formData = await req.formData()
for (const key of formData.keys()) {
console.log(`${key}: ${formData.get(key)}`)
}
return new Response('ok', {
status: 200
})
}

まず通常の方を見てみると、フォームのnameがキーとして利用されており、素直なフォームデータが送られています。

name: しぶかわ
email: shibukawa@example.com

次にServer Actionsを見てみます。

image.png

サーバー側のハンドラで受け取ったコンテンツは次の通りで、$ACTION_IDなる項目が増えています。

$ACTION_ID_0826d283a8eec2e3e98a3ef9e3e4269aca493681: 
name: しぶかわ
email: shbiukawa@example.com

しかし、開発者ツールで見ると、キーの名前などが改変されていたり、ハンドラで受け取っていない値なども入っていますね。Next.jsのサーバー側のコードが、このあたりを成型してからハンドラを呼んでいるようです。

image.png

まず、送り先ですが表示しているページと同じURLにPOSTで送っています。ちょっと変わったところとしては、いくつかNext-がつくヘッダーフィールドを送っています。

image.png

Next-Actionの方はリロードしても値は変わらないため、Server Actionsのハンドラの識別子なのではないかと思います。別パスに同じ内容で作成しても別のIDになりました。よく見ると、Next-Actionヘッダーフィールドの値と、$ACTION_IDは同じですね。いろいろついていますが、curlで次のようなリクエストを送ったところ、外からもServer Actionsのハンドラを起動できました。CSRFトークンのようなものはなく、わかってしまえば外からリクエストが投げられてしまうというのは、パブリックに公開するAPIの場合はちょっと警戒しておいた方が良さそうですね。

$ curl -F '1_$ACTION_ID_0826d283a8eec2e3e98a3ef9e3e4269aca493681='
-F '1_name=shibukawa' -F '1_email=shibukawa@example.com'
-F '0=["$K1"]'
-H 'Next-Action: 0826d283a8eec2e3e98a3ef9e3e4269aca493681'
http://localhost:3000/

クッキーの認証情報を取得する方法もサンプルにありますが、ヘッダー値も取得できます。いきなりSQLを呼ぶとか話題になっていますが、セキュリティの防護はいつも通り行う必要がありそうです。次のコードはヘッダーとクッキーをすべてダンプしています。必要な情報を取得してリクエスト元の認証や認可の検証をしたり、入力情報のバリデーションは行いましょう。

async function onAction(formData: FormData) {
"use server"
for (const [key, value] of headers()) {
console.log(`header: ${key}: ${value}`)
}
for (const cookie of cookies().getAll()) {
console.log(`cookie: ${cookie.name}: ${cookie.value}`)
}
cookies().set("sample", "test")
}

お客さんから教えてもらったのですが、React-Hook-Formにはzodを使って検証できるアダプターがあるらしいので、これを使えばクライアントのフォーム検証とサーバーの検証を同じzodのモデルを使ってできそうですね。

おまけで、binding argumentsの仕組みを使ったら簡単にCSRFトークンの実装ができるかも試してみましたが簡単にはできませんでした。厳密にやるなら、クライアントコンポーネントにしつつ公開鍵も渡しつつ、それで署名して、サーバー側で秘密鍵で検証とかでしょうか。結構大がかりになってしまいますが。

まとめ

Server Actionsの実態としては、ちょっとキー名が改変されたリクエストを受け取るハンドラが生えて、そこに対するリクエストを投げるような動きをしていることが分かりました。

そのあたりの実装の手間が省けるのは便利ではありますが、外からリクエストが通ってしまうため、悪意のあるクライアントからのリクエストをチェックしてサニタイズするというロジックは別途必要なんだろうな、と思います。Next.jsのドキュメントに書いてあることとずれてしまいますが・・・