🥶

Next.jsで簡単なCRUDアプリを作りながら気になったセキュリティ: Railsの視点から

2024/10/11に公開
5

先日、Kamal 2でNext.jsを安価なVPSにデプロイする勉強をしながら、Next.js App Router/Server ActionでCRUDのデモアプリを作成しました(コードはGitHub)。そのときにセキュリティについて気になって点がいくつかあり、勉強しながら対策をしましたので紹介したいと思います。

私自身は業務でNext.jsを書いた経験が限定的です。的外れな議論をしているかもしれません。あくまでもRuby on Railsアプリを書くときと同じ気持ちでNext.jsのアプリを書いたとき、セキュリティ上で気になった点を挙げているだけです。私が見落としている点や誤っている点等ありましたら、コメントやX等で教えていただけると大変ありがたいです。

その1:データ漏洩の危険性

この問題についてはムーザルちゃんねるが紹介しています。またNext.jsの公式ブログでも対策が紹介されています。

例えば下記のようなServer ComponentのPageがあり、かつEditUserFormがClient Componentだった場合を想定します。そうするとUserテーブルの情報(userオブジェクト)が全てネットワーク越しにブラウザに送られるのが問題です。もしUserテーブルに暗号化されたパスワードや最後にアクセスしたIPアドレスの情報が含まれていれば、これもすべてブラウザに送られます。HTMLにはnameidemailしか表示しない場合であってもuserのデータはすべて送信され、ブラウザの開発者ツールから簡単に見られてしまうのです。

app/users/[id]/edit/page.tsx
export default async function UserPage({params}: { params: { id: string } }) {
  const user = await prisma.user.findUnique({
    where: {id: ParseStringAsNumber(params.id)}
  })

  return (
    <TopNavigation title="Edit User" current="users">
      <div className="absolute top-0 right-0 flex items-center justify-end gap-x-6">
        <Link href={`/users/${user.id}`} className="text-sm font-semibold leading-6 text-gray-900">
          Cancel
        </Link>
        <button
          form="edit-user-form"
          type="submit"
          className="rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600"
        >
          Update User
        </button>
      </div>
      <EditUserForm user={user}/>
    </TopNavigation>
  )
}

この問題は別にNext.jsに固有のものではなく、ブラウザでReactがhydrationされ、インタクラティブになる場合は起こり得ます。HydrationはフルのReactコンポーネントを作りますので、フルのデータが必要というわけです。今回はEditUserFormがClient Componentですので、該当するケースです。

一方でRails ERBなどのHTMLテンプレートを使ったMPAはこの問題が起こりません。インタラクティブであってもブラウザ側のhydrationは不要ですので、ブラウザに送信されるのはHTMLとして表示されるものだけで十分です。暗号化されたパスワードは画面に表示しませんので、送られません。

対策として公式ブログで紹介されているもの

Next.jsの公式ブログに書いている対策はData Access Layerをおいて、Data Transfer Object (DTO)を作成するというものです。ただしMartin Fowler氏がどちらかというとアンチパターンとして紹介しているLocal DTOに近く、メリットの割に手間がかかりそうだと感じました。「本気でこれをアーキテクチャーのレイヤーとして、すべてのテーブルでDTOを書くの?」っていう気持ちです。

またこのDTOはデータベースからデータ取得もしていますので、責務がRepositoryと被ってしまいます。多くのプロジェクトではそのまま導入するのは難しいと感じます。

私がやってみた対策

私がやってみた対策は、よりシンプルなものです。(その他のコードも含まれていますが)GitHubに公開しています

app/users/[id]/edit/page.tsx
export default async function UserPage({params}: { params: { id: string } }) {
  const rawCurrentUser = await authenticateAndReturnCurrentUser()
  const rawUser = await getUser(params.id) || notFound()
  if (!userPermission("update", rawUser, rawCurrentUser)) { redirect("/sessions/create") }

  const user = rawCurrentUser &&  _.pick(rawUser, "name", "email", "id")

  return (
    <TopNavigation title="Edit User" current="users">
      <div className="absolute top-0 right-0 flex items-center justify-end gap-x-6">
        <Link href={`/users/${user.id}`} className="text-sm font-semibold leading-6 text-gray-900">
          Cancel
        </Link>
        <button
          form="edit-user-form"
          type="submit"
          className="rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600"
        >
          Update User
        </button>
      </div>
      <EditUserForm user={user}/>
    </TopNavigation>
  )
}

ここではlodashpick()を使って、データの絞り込みをしています[1]getUser()は単純にすべてのUserデータを取得する関数です)。他のことが書いてあるので分かりにくいのですが、実際にデータの絞り込みをやっているのは下記のところだけです。これだけで絞り込めます。

_.pick(rawUser, "name", "email", "id")

こうすればDTOクラスを個別に定義する必要はなく、各ページに埋め込んだ簡単なコードでデータの絞り込みが実施できそうです。またpick()処理が済んでいないデータにはraw*という接頭語をつけることにより、誤ってComponentに渡してしまわないように工夫しています。

考え方はRuby on Railsのstrong parametersにヒントを得ています。当初はRailsもDTO的にActiveRecord側で制限をかけていました。しかし、2012年にこれをコントローラ側に持ってきました。理由は、このようなアクセス制御はmodelではなく、controllerでやるべきだという判断です。同じように今回のデータ漏洩リスクについても、DTOのようにデータベースに近いところで制御するのではなく、Viewの直前で制御した方が良さそうに思いました。

その2:Mass Assignmentの脆弱性

Mass Assignmentの脆弱性は古くからのRuby on Railsユーザにとっては有名な脆弱性です。何しろあのGitHubが2012年にやられたのですから(被害があったわけではなく、PoCとして侵入されたらしい)。

これを防ぐのは、先にも言及したRuby on Railsのstrong parametersです。

データベースのレコードを作成したり、アップデートしたりするとき、すべてのフィールドを列挙するのは大変です。そのため、下記のようにオブジェクトを丸ごと受け取るような関数を書くことが多いです。しかし、もしUserにisAdminのようなフィールドがあれば、悪意のあるハッカーがisAdminフィールドを含むリクエストを投げることで、アドミ権限を含めて丸ごと変更されてしまう可能性があります。結果として本来はアドミ権限を持ってはいけないUserがアドミになってしまいます。

app/repositories/user_repository.ts
export async function createUser(user: Prisma.UserCreateInput) {
  return prisma.user.create({data: user})
}

これを防ぐために、Ruby on Railsをはじめとした多くのフレームワークはMass Assignment対策を施しています。例えばLaravelにはEloquent ORMで$fillableを要求します。しかしNext.jsは特に何も用意していませんので、開発者自身が気をつける必要があります。

アップデート: フィードバックを見ていたら、習熟度が高い開発者でもmass assignmentの脆弱性を意識していない方が多いように見受けられました。少し調査した結果、これはPrisma ORMがORMというよりはクエリビルダーの性質が強く、モデルを保存する概念が薄いことが原因のように思えてきました[2]

対策

下記のようなServer Actionが危ないです(絶対に真似してはいけません)。ブラウザから送られてきたformDataを、そのままPrismaに送ってしまい(prisma.user.update)、データベースのアップデートをしています。これだと本来は更新を許可したくないフィールドもアップデートされてしまう可能性があります
(ちなみに私の環境ではTypeScriptはエラーを出さず、見過ごしていました...)

export async function updateUserAction(
  userId: string | number,
  previousState: ValidationUserErrors,
  formData: FormData
) {
  const user = await getUser(userId) || notFound()
  const data = Object.fromEntries(formData)

  await prisma.user.update({where: {id: user.id}, data})

  revalidatePath("/")
  redirect("/users")
}

対策そのものは簡単です。基本的には下記のいずれかを行います

  • Object.fromEntries(formData)を使わずに、必要なフィールド一つ一つをformData.get("name")で取ってくる
  • Object.fromEntries(formData)は使うけれども、データベースに送る前にlodashのpick()Zodなどを使って、必要なフィールドだけに絞り込む[3]

個別にフィールドを取ってくる場合は下記のようなコードになります。

const data = {
  name: formData.get("name") as string,
  email: formData.get("email") as string
}

Pickを使った場合は下記のようになります。

const data = _.pick(Object.fromEntries(formData), "name", "email") as {name?: string, email?: string}

Zodを使った場合は下記のようになると思います(試していないのでバグがあるかもしれません)。

const rawData = Object.fromEntries(formData)
const schema = z.object({
  name: z.string().min(1),
  email: z.string().email({message: "Invalid email"}),
})
const validatedFields = schema.safeParse(rawData)
const data = validatedFields.data
// 通常はバリデーションエラーを画面に表示したりという処理もします

なお、私が作ってみたアプリでやっているのは、前者の必要なフィールドを一つ一つ取るやり方です。Zodは別途validateUser()の中で使いたかったためです。

このようにNext.jsはフレームワークとしてmass assignment対策を強制しません。そのため方針を固め、方針を守った開発を行い、しっかりレビューし、必要に応じて監査するという対応が必要だと思いました。

なおRuby on Railsのstrong parametersでは、HTTPリクエストから送られてきたパラメータは危険であることを示すために、ActionController::Parametersクラスでラップしています。明示的にフィールドを許可しない限り、どんなに不注意でもmass assignmentの脆弱性が生じません。考え方としてはReactの"experimental"なtaintと似ていると思いますので、Reactのtaintが今後発展することに期待しています。

その3:Open Redirectの脆弱性

Next.jsのredirect()関数Open Redirectの脆弱性の対策がされていません。ユーザを偽ウェブサイトに誘導するのに悪用されてしまう危険性があります。

対策

今回はisSafeRedirect()関数を自作し、これでリダイレクトが安全かどうかを判別しています。下記はデモアプリの中でisSafeRedirect()を使用したコードです。

ただしフレームワークの機能ではありませんので、外部から送られてきたパラメータを使ってリダイレクトする際、開発者自身が必ずisSafeRedirect()を通す必要があります。開発者およびレビューワーが状況判断をし、必要なときは`isSafeRedirect()で囲むようにしなければなりません。

なおRails 7.0ではデフォルトでリダイレクトが安全かどうかを確認してくれますので、このような対策は不要です。allow_other_host: trueと設定されたケースだけ注意すれば十分です。Laravelも外部サイトにリダイレクトするときはaway()メソッドを使う必要があり、安全性を保っています。

app/sessions/actions.ts
export async function createSession(formData: FormData): Promise<void> {
  const user = await getUser(formData.get('userId') as string) || notFound()

  const session = await getSession()
  // Renewing the session after login to prevent session fixation attacks.
  // https://en.wikipedia.org/wiki/Session_fixation
  // This may be unnecessary with the iron-session scheme
  session.destroy()
  session.userId = user.id
  await session.save()

  const headersList = headers();
  const host = headersList.get('host')
  const redirectLocation = formData.get("referer") as string || "/"

  if (isSafeRedirect(redirectLocation, host)) {
    redirect(redirectLocation)
  } else {
    throw new Error(`Cannot confirm safety of redirect location: ${redirectLocation} from host: ${host}`)
  }
}

感想

セキュリティは非常に重要なのですが、難しそうだというイメージもあり、学習が後回しになりがちです。また 「フレームワークが守ってくれるはず!」 と考えている人が多く、実際にRuby on RailsやLaravelを始めとしたバックエンドに強いフレームワークはその期待に沿う形で発展してきました。現実問題として、バックエンドエンジニアであっても、日頃からセキュリティを強く意識する必要がないと言っても良いかもしれません

ただしNext.jsは事情が違います。バックエンドフレームワークとしての歴史の浅さが原因かもしれませんが、上記で解説したように、一転してセキュリティを常に意識する必要が出てきます。正直、デモアプリを書くだけで私はとても疲れました。

なおセキュリティを真剣に学びたい場合、Railsガイドにはセキュリティに関する非常に充実したセクションがあります。Railsに限らず、一般のウェブ開発でも有用なリソースだと思います。

脚注
  1. Lodashのpick()を使っているのは、型推論をしてくれるためです。型推論が不要な場合は自作しても良いでしょう。 ↩︎

  2. Mass assignmentは、HTTPリクエストのパラメータを自動的にモデルに割り付ける処理を言います。これは多くのフレームワークで一般的に利用されています。そしてそれの悪用を防ぐためのmass assignment脆弱性対策も一般的です。ところがインターネットで公開されているコードを見た限り、Prismaを使っている場合は例外なく、そもそもmass assignmentが使われていませんでした。各HTTPリクエストパラメーターをマニュアルで一つ一つPrismaのクエリに渡していました。Mass assignmentを使っていないので、脆弱性対策も意識しなくて良かったわけです。一方で同じNode.jsであっても、Nest.jsとTypeORMの組み合わせではmass assignmentが行われ、FormDTOというパターンで脆弱性対策が一般に行われていることも確認できました。このことから推論されるのは、Prismaが一般的なORMではなく、どちらかというとクエリビルダーであるため、mass assignmentのパターンが一般化しなかったのではないかということです。ただし一般化しなかっただけであり、Prismaであっても上述のようにmass assignment的なことをすることは容易に可能です。 ↩︎

  3. Zodは絞り込みの他にバリデーションもやってくれますので、Zod一択ではないかという意見も多いかと思います。しかし上に示したように、絞り込みだけをするのであればZodは大袈裟な感じがします。pickの方がコードはシンプルであり、絞り込みという責務に集中できています。もし異なる権限を持った利用者(例えばアドミと一般ユーザ)がシステムを使う場合、それぞれでmass assignment対策として絞り込むべきパラメータは異なるけれども、一方でバリデーションは同じというケースが発生します。この場合はmass assignment対策としての絞り込みとバリデーションを別個に処理したくなります。そのためにZodではなくpickを選択する方法もあると思います。他フレームワークもmass assignment対策とバリデーションは分かれていることが多いと思います。 ↩︎

Discussion

Honey32Honey32

DTO と Mass Assignment のところ、分かりそうでハッキリしてない所だったので、参考になりました!

非常に細かい所で恐縮ですが、下のコードの data の型は、unknown のような広い型 ( {[k: string]: FormDataEntryValue;} ) になるので、data をキチンと型付けされた関数に渡すとエラーが出るはず…だと思います。

 const data = Object.fromEntries(formData)

https://www.typescriptlang.org/play/?target=10#code/CYUwxgNghgTiAEYD2A7AzgF3gMyTAtgCJQZQBc8AYnkSVAFD3LpYCuADsCQgLzwAUrNCBgUA3iij4QFTDACWKAOYBfAJTweAPnhiVjZpnjAMSTfADyAIwBW4DADpsMJPgCiKDApBp+uAsSkavQA9CHwEfAAegD89BxcGCD8JkjBQA

NaofumiNaofumi

ありがとうございます!
私がやってみたのは下記のようなコードです。

あまりTypeScriptに詳しくないので、原因分析はできていないのですが、型エラーが出ませんでした。

const data = Object.fromEntries(formData)
await updateUser(data, currentUser.id)

async function updateUser(user: Prisma.UserUpdateInput, userId: number) {
  return prisma.user.update({where: {id: userId}, data: user})
}

// Prismaが作ってくれたType
export type Prisma.UserUpdateInput = {
  email?: StringFieldUpdateOperationsInput | string
  name?: NullableStringFieldUpdateOperationsInput | string | null
  posts?: PostUpdateManyWithoutAuthorNestedInput
}
Honey32Honey32

特定できました!!

optional なプロパティ (email?: 型) しか持たない型に対して、 {[k: string]: FormDataEntryValue;} 型が代入可能なのでエラーが潰れてしまったようです。

https://www.typescriptlang.org/play/?target=10#code/CYUwxgNghgTiAEYD2A7AzgF3gMyTAtgCJQZQBc8AYnkSVAFD3LpYCuADsCQgLzwAUrNCBgUA3iij4QAfgqYYASxQBzAL4BKeDwB88MWsbNM8YBiTb4AeQBGAK3AYAdNhhJ8AURQYlINP1wCYlINegB6MPgo+AA9GXoOLgwQfjMkUKA

一つでも省略不可能なプロパティを追加すれば、コンパイルに失敗してくれます

https://www.typescriptlang.org/play/?target=10#code/CYUwxgNghgTiAEYD2A7AzgF3gMyTAtgCJQZQBc8AYnkSVAFD3LpYCuADsCQgLzwAUrNCBgUA3iij4QAfgqYYASxQBzADTwQ+KIojyMS1QF8AlPB4A+eGKONmmeMAxJz8APIAjAFbgMAOmwYJHwAURQDRRA0flwCYlITegB6JPg0+AA9GXoOLgwQfickRKA

原因は分かりましたが、これはお手上げですね…

DB のデータを更新するためのメソッドなので、どうしても省略不可能にできるプロパティが無いのだと思います

PaalonPaalon

私は Ruby on Rails の住人でも Next の住人でもありませんが、双方の住人の観点からみたお互いの気になる点の一部に光を当ててくださってありがとうございます。私の母は Rails の住人で Ruby の住人でもあって、静的型付き言語の住人である私とプログラミングの話をすると、考え方が違うんだなーということに気付いて、最近 Rails や Ruby の動的型付けの住人の思想を静的型付けの住人にも伝わるように言語化しようと試みています。

そんな私がパッと見て思ったのは、この記事で注目している点とは関係ないと思うのですが、updateUserAction の実装の userId: string | number あたりの記述が気になってしまいます。呼び出している函数の実装 (1, 2) を見ると、動的型判定による処理がされているので、これでは静的型付けの意味がありません。つまり、設計論的には string | number という記述をわざわざする意味がそもそもなくなってしまっていることになります。通常、この場合は userId: number とだけ(TypeScript でなければより必要十分であるような型、例えば userId: UnsignedInteger など)になるように設計するのが正しいと思います。型によって処理が異なる同一名の函数を作りたくなったら適切な多相化手段(静的型によるアドホック多相化かパラメター多相化)を取るべきです。
疑似コードで言うなら、

function f(x: A | B) {
    if (typeof(x) == A) {
        f_for_A(x);
    } else if (typeof(x) == B) {
        f_for_B(x);
    } else {
        // ...
    }
}

と動的に多相化するのではなく、

function f(x: A) {
    ...
}

function f(x: B) {
    ...
}

として静的にアドホック多相化するか

function f<T>(x: T) {
    ...
}

と静的にパラメター多相化するということです。

こういう処理を書いてしまうのは動的型付け言語を普段書いている人が静的型付け言語を初めて書いたときに遭遇する典型的な例なので、他の点(私はウェブアプリケーションのソフトウェア開発の専門家ではないので、パッと見では分からないのですが)も典型的な悪い設計をしているかもしれません。

NaofumiNaofumi

ありがとうございます。

動的型付け言語を普段書いている人が静的型付け言語を初めて書いたときに遭遇する典型的な例

これは私のことなので、典型的だということであれば、まさにご指摘のとおりです。

ただし考えがなかったわけではありません。string | numberではなくnumberだけを許容することも考えましたが、以下の理由により、そうしませんでした。

  1. HTTP paramsは一般にstringとして送信されますので、numberに変換するためにはキャストをする必要があります
  2. stringをnumberにキャストするときに最もよく使われるのはparseInt()もしくはNumber()だと思いますが、人によってparseInt()を使ったり、Number()を使ったりしてしまわないかという疑問がありました。そこでParseStringAsNumber()ヘルパーを作りました。具体的にはNumber("")0になり、parseInt("")NaNになるのが気持ち悪いです(双方とも同じ挙動になってほしい)。
  3. Server ActionとかServer Componentの中でstringをnumberにキャストすることを要求すると、parseInt()とかNumber()を使うか、実装する人によってバラつきが出てしまい、それが気持ち悪いです。だったらrepositoryの関数にはstringのまま渡してあげて、repositoryの中で統一したキャスト(ParseStringAsNumber())をしたら良いのではないかと思いました。

なおRuby on RailsのActiveRecordの場合は、データベースのフィールドがInteger型であれば、ActiveRecord::Type::Integer.new.cast([値])で自動的にキャストをするので、キャストの挙動が統一されます。私はこっちに馴染んでいることもあり、この処理をまねました。

詰まるところ、「キャスト挙動の統一」をどのように担保するかで悩んだということだと思います。そして解決策として、Server ActionやServer Componentでキャストせずに、repositoryでキャストする方法を選択しました。

静的型付けをする人がRailsのコードを書くこと、逆のことはよく見かけます。ActiveRecordが自動的にキャストをしてくれるのに、Controllerで手動でキャストする人がいて、無駄なのになぁって思ったりします。キャストをせずに、そのままでもっと引っ張れば良いのにって思ったりします。あるいはさっさとActiveRecordに突っ込んで、自動でキャストしてもらってから、ロジックはActiveRecordの中に書けば良いのにって思います。

一言でいうと、静的型付けをする人は早い段階で捌き、動的型付けをする人は後ろで捌くのかもしれません。私は後ろで捌いた方がDRYになり、処理の統一ができると感じていることもあり、まさにご指摘のとおりに後ろで捌くのが好きです。