🥵

Next.jsのlayout.tsxで認証チェックすると情報漏洩するかも

2024/03/12に公開

Next.jsの認証チェックどこでするか問題

基本的には middleware.ts で行うと思うのですが、肥大化を避けたり、ちょっとした共通処理は layout.tsx に書くこともあるでしょう。今回は layout.tsx で認証チェックをした場合に、実装によっては意図せず認証ユーザにしか表示したくない情報が漏洩してしまうかもしれないケースを紹介します。

問題のあるコード

/protected/layout.tsx
import { redirect } from "next/navigation";

export const dynamic = 'force-dynamic';

function currentUser() {
  // ここでセッションデータから認証ユーザ情報を取得する関数
  // デモ用にログインしていないユーザを再現したいのでfalseを返す
  return false;
}

export default function Layout({ children }) {
  // ログインしていないユーザは、ログインページへリダイレクトさせる
  if (!currentUser()) {
    redirect('/login');
  }

  return (
    <div>{children}</div>
  );
}
/protected/page.tsx
async function fetchData() {
  // データベースから情報を取得する
  const data = await database.query();
  // data には「秘匿情報だよ」という文字列が入っているとする
  return data;
}

export default async function Page() {
  // 本来ログインしているユーザにしか表示したくないデータ
  const data = await fetchData();
  return (
    <div>{data}</div>
  )
}

さてこのページにブラウザでアクセスをすると /login ページにリダイレクトされます。これでちゃんと認証チェックが動いているように感じますが、実は違います。今度はブラウザではなく、curlで試してみましょう。

# レスポンスヘッダも見たいので --include オプションを付ける
curl --include http://localhost:3000/protected

すると以下のような結果が得られます。

HTTP/1.1 307 Temporary Redirect
Location: /login
// 略
<script>self.__next_f.push(
[1,"4:[\"$\",\"div\",null,{\"children\":\"秘匿情報だよ\"}]\n
8:[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"1\",{\"charSet\":\"utf-8\"}]]\n3:null\n"])</script><script>self.__next_f.push([1,""])</script></body></html>⏎

Location ヘッダを使って /login へリダイレクト指示されていますが、レスポンスボディもあります。そしてその中をよく見てみると…「秘匿情報だよ」という文字列がありますね。本来であればログインしているユーザにしか表示したくないデータです。しかし、このようにcurlでリダイレクト前のボディに含まれてしまっています。危ないです。

これはNext.jsのレイアウトとページの処理順が関係しています。実はページ→レイアウトの順に処理されます。非同期処理の場合など細かくは異なるので詳細はこちらの記事を参照してください。

改善するには?

素直に middleware.ts で認証チェックするのが良いと思いますが、それ以外にも回避策はあります。layout.tsx ではなく情報を実際に出力するコンポーネントで認証チェックをすることです。

/protected/page.tsx
export default async function Page() {
  // 本来ログインしているユーザにしか表示したくないデータ
  if (!currentUser()) {
    redirect('/login');
  }
  const data = await fetchData();
  return (
    <div>{data}</div>
  )
}

これであれば秘匿情報が事前に配信されることはありません。

まぁ、実際のアプリケーションではここまで雑な条件が揃うことはないので、問題ないかもしれませんが知らずに開発をしていると恐ろしいことになるかもしれないので気をつけましょう(自戒)。

ムーザルちゃんねる

Discussion