🥶

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

2024/10/11に公開1

先日、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()を使って、データの絞り込みをしています(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は特に何も用意していませんので、開発者自身が気をつける必要があります。

対策

下記のような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)は使うけれども、データベースに送る前にZodなどを使って、必要なフィールドだけに絞り込む。上記のlodashのpick()だけでも十分

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

const data = {
  name: formData.get("name") as string,
  email: formData.get("email") as 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に限らず、一般のウェブ開発でも有用なリソースだと思います。

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