Slide 1

Slide 1 text

Next.js セキュリティ #Offers_DeepDive 2024.11.06 @zaru / nanabit

Slide 2

Slide 2 text

自己紹介 @zaru ムーザルちゃんねるから来ました nanabit という会社で開発支援してます

Slide 3

Slide 3 text

今日は Next.js App Router の セキュリティで気をつけたい話をします

Slide 4

Slide 4 text

Next.js App Router の簡単な紹介

Slide 5

Slide 5 text

Server Component Streaming with Suspense Server Actions 主に React19 の新機能を先取りして提供

Slide 6

Slide 6 text

Server Component Streaming with Suspense Server Actions 今日は主にこの 2 つに関連する話

Slide 7

Slide 7 text

Server (Component|Actions) is 何? フロントエンドとバックエンドの境界線をなくす コンポーネントから直接 SQL 実行できる フロントの状態を極力持たずに書ける バックエンドの処理を関数で呼び出せる

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

じゃあ、 雰囲気でなんとなーく理解したところで、 本題に入りましょう。 今日はちょっとしたゲームを Next.js で作ってきたので、 それで遊びます。 このゲームには脆弱性がいくつかあります。 簡単なので調査してみてね。 (*) DoS とかしちゃだめだよ。 そういうのじゃないよ。

Slide 10

Slide 10 text

Next Clicker 今すぐアクセスして脆弱性を見つけよう

Slide 11

Slide 11 text

https://nextclicker.nanabit.dev/ 今日の解説に使う題材です

Slide 12

Slide 12 text

見つかりましたか? では答えです

Slide 13

Slide 13 text

無防備な Server Actions

Slide 14

Slide 14 text

Ⓝ をクリックすると、 通信が発生している

Slide 15

Slide 15 text

Request Header で Next-Action を送信してる curl で再現するとこんな感じの POST 通信 curl 'https://nextclicker.nanabit.dev/' \ -H 'Content-Type: text/plain;charset=UTF-8' \ -H 'Cookie: ...' \ -H 'Next-Action: 6eb8416051487420c0347306825a392adf55f29e' \ --data-raw '[1]' 手動でリクエストを試してみる

Slide 16

Slide 16 text

--data-raw '[9999]' の数値をいじると、 1 クリックで上がる 数値を変更可能なことが分かった curl 'https://nextclicker.nanabit.dev/' \ -H 'Content-Type: text/plain;charset=UTF-8' \ -H 'Cookie: ...' \ -H 'Next-Action: 6eb8416051487420c0347306825a392adf55f29e' \ --data-raw '[9999]' # 1 クリックで9999 上がる 任意の数値でスコアを挙げられることを発見

Slide 17

Slide 17 text

"use server"; export async function incrementalScore(power: number) { const user = await currentUser(); if (!user) return; await prisma.user.update({ where: { id: user.id }, data: { score: { increment: power } }, }); revalidatePath("/"); } Bad: 引数で上げる数値を受け取り信頼してしまっている Server Component 関数の実装

Slide 18

Slide 18 text

Server Actions = HTTP エンドポイント 見た目は関数を呼び出しているが HTTP エンドポイント 自動でエンドポイントをたて、 内部で fetch している つまり外部に公開されているので引数は信頼できない 引数は必ずパースし、 必要なら認証もする 外部公開 API と捉えて設計と実装をすること こう考える

Slide 19

Slide 19 text

露出してる Server Actions

Slide 20

Slide 20 text

DevTools の Search で [0-9a-z]{40} で検索する Request Header で Next-Action で使う ID が見つかる Server Actions の探し方

Slide 21

Slide 21 text

curl 'https://nextclicker.nanabit.dev/' \ -H 'Content-Type: text/plain;charset=UTF-8' \ -H 'Next-Action: 8d2238ce9fde355707d6a4b613a12f5d5360427c' \ --data-raw '[1]' 0:["$@1",["EBYDKshKtbxTnpEuKdpC4",null]] 1:{"id":1,"name":"zaru","password":"$$2b$10$k6BnP5...","score":280,"level":0} ハッシュ化されたパスワードが返ってきた・ ・ ・! 適当にリクエストしてみる

Slide 22

Slide 22 text

"use server"; は Server Actions にするディレクティブ 名前から 「サーバ処理をする時に付けるもの」 と勘違い 盲目的に、 DB を叩く関数すべてにつけてしまった・ ・ ・ "use server"; # 内部でしか使ってないのに、 意図せず外部公開されてしまった関数 export async function fetchRankers() { return prisma.user.findMany({ orderBy: { score: "desc" }, take: 10, }); } 意図せず Server Actions になってしまった例

Slide 23

Slide 23 text

"use server"; != サーバ処理 上記を理解していても 1 ファイルに複数の関数を export して いると、 気が付かずに ServerActions になってしまうものが出 てくる可能性 v15 ではID が露出しないように改善された Knip というツールで未 export 関数の検出可能 こう考える

Slide 24

Slide 24 text

Client Component に漏れる

Slide 25

Slide 25 text

DevTool を使ってユーザ名を検索・ ・ ・ めっちゃパスワードがブラウザ側に露出している 情報漏洩してないか検索

Slide 26

Slide 26 text

Server Component で取得したユーザ情報をそのまま Client Component に渡している // Ranking はServer Component export async function Ranking() { const users = await fetchRankers(); return ( {users.map((user) => ( // RankingItem はClientComponent ))} ); } Server と Client の境界線

Slide 27

Slide 27 text

コンポーネント内部で使っているかどうかは関係ない Server Component は JSX に使っているものだけ露出する 面倒くさがってオブジェクト全部渡しちゃったり・ ・ ・ 構造的片付けで意図しないプロパティが含まれてしまうケ ースも ClientComponent の props は全て露出

Slide 28

Slide 28 text

必要な情報だけ props に渡す コンポーネントが Server か Client かで考えると境界線意識 がつらい どんなコンポーネントでも必要な情報だけ扱うようにする TaintAPI や Pick 型/Zod パースなどを使う それぞれ効能が少し異なるのでシーンによって使い分ける こう考える

Slide 29

Slide 29 text

SQL なら原則 SELECT 句は指定した方が良い export async function fetchRankers() { return prisma.user.findMany({ // Prisma の例 select: { id: true, name: true, score: true, level: true }, }); } SELECT 句を明示的にする

Slide 30

Slide 30 text

React の提供する Taint API を使うと使用禁止をマークできる サーバ側でしか使わないようなオブジェクトに対して利用可能 ただし、 実行時エラーなのでエディタ上では分からない export async function fetchRankers() { const users = await prisma.user.findMany(); for (const user of users) { // user オブジェクトをClient Component に渡すとエラーになる taintObjectReference("Client Component では使えません", user); } return users; } Taint API を使う

Slide 31

Slide 31 text

ライブラリでパースし、 必要ないプロパティを落とす const schema = z.object({ id: z.number(), name: z.string(), }); // パースすると、 id / name 以外のプロパティは消える const parsed = schema.safeParse(user); Zod でパースする

Slide 32

Slide 32 text

認証してるのに見える

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

条件分岐で弾かれているのにコードに含まれちゃってる またも DevTool で検索する

Slide 35

Slide 35 text

No content

Slide 36

Slide 36 text

Layout で認証しない v15 では Layout からレンダリングされるので回避は可能 ただし順序に依存すると Next.js の変更で露出するかも 隠したい情報を表示するコンポーネント自体で判定させる 将来的には Request Interceptors で共通処理できるかも https://github.com/vercel/next.js/pull/70961 こう考える

Slide 37

Slide 37 text

仕組みを理解して使うことが大事 フロントとバックの境界線を意識する この先、 境界線をまたぐ書き方は増えていく気がする 自分の領域を決めすぎず広く対応していきたい

Slide 38

Slide 38 text

import 'server-only'; で Client Component に含ませない next-safe-action という Server Actions を少し安全にする https://next-safe-action.dev/ 似たようなライブラリは他にもいくつかある おまけ

Slide 39

Slide 39 text

おしまい もし Web アプリの開発・技術顧問、 エンジニア組織・文化の相談あれば、 @zaru までお問い合わせください