紡Book の勉強ノートに戻る
コードレビュー学習2026.04.17

一覧に出していない記事が、URL直叩きで見えていた — Next.js 動的ルートの落とし穴

コードレビューNext.jsセキュリティ

コードレビューで見つかった問題を、勉強のためにまとめたものです(修正済み)。


この記事でわかること

  • Next.js の動的ルートで「一覧に出さない記事」が URL 直叩きで見えてしまうパターン
  • generateStaticParams / dynamicParams の責務の誤解と、その対策

概念

「一覧に出さなければ誰も辿り着けないから大丈夫」と考えがちだが、URL を直接叩けば動的レンダリングで返ってきてしまいます。generateStaticParams はビルド最適化の仕組みであって、アクセス制御ではありません。

下書きの本文はファイル。一覧の slug はビルド時のメモリで、鍵ではない。ディスクcontent/blog/*.mdxfrontmatter の statusdraft / publishedファイルに保存されるビルド時の slug 一覧generateStaticParamspublished の HTML を事前生成メモリ・保存しないアクセス制御ではないURL 直叩き/creation-notes/blog/下書きリクエストは保存しない一覧に無くても届くdynamicParams=truegetPost は関所で、置き場所ではない。修正前はファイルがあれば返す。status は見ない。直叩きがここへ届くので、ディスク上の下書き本文がページになる。

修正前のコード

ブログ機能実装時、generateStaticParams() が published 記事のみをビルド対象にしていたが、getPost(slug) 関数がファイルの存在確認(ファイルが読めれば返す)しかしておらず、status: "draft" のチェックがなかった。

tsx
// 修正前
export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  let post;
  try {
    post = getPost(slug);  // status チェックなし → dynamicParams=true なので一覧外の slug でもここに到達し、draft も返る
  } catch {
    notFound();  // Next.js の関数。404ページを返す
  }
  return <BlogLayout post={post} />;  // draft でもそのまま表示
}

URL /creation-notes/blog/draft-article-slug を直接叩けばドラフト記事が表示されてしまいます。generateMetadata も同様にドラフトの OGP を生成してしまいます。

なぜ危険か

  • 情報漏洩: まだ公開予定でない記事、内部情報、不完全な内容が外部に見えます
  • SEO クロール: Googlebot が動的アクセスするケースもあり、下書きがインデックスされる可能性があります
  • 意図しない公開: 「一覧には出していないから大丈夫」という誤った安心感につながります

対策

draft を弾くロジックは 記事取得の入り口1か所に集約 するのが安全。綴Photo は getPost の中で status を見て、published でなければ例外を投げる方式を採った。

ts
// blog.ts — 取得の入り口で一元管理
// SHOW_DRAFTS: 本番ビルド(next build)では false、ローカル(next dev)では true になる
const SHOW_DRAFTS = process.env.NODE_ENV !== "production";
 
export function getPost(slug: string) {
  // ... ファイルを読む ...
  // frontmatter に status が書いてなければ "published" 扱い(?? は「左が空なら右を使う」演算子)
  const status = data.status ?? "published";
  // 「本番」かつ「draft」のときだけ弾く=ローカルでは draft も読めてプレビューできる
  if (!SHOW_DRAFTS && status !== "published") {
    throw new Error(`Post "${slug}" is not published`);
  }
  return { meta, content };
}

SHOW_DRAFTS を挟んでいるのは、ローカル(next dev)では draft を確認できて、本番ビルドでは弾くためのスイッチ。これで一覧(getAllPosts)も個別ページも、同じ判定を通ります。

公開可否はファイル。スイッチは環境変数。判定は getPost が両方を読む。ディスクcontent/blog/*.mdxfrontmatter の statusファイルに保存されるプロセスの環境変数NODE_ENV → SHOW_DRAFTS本番は false / ローカルは trueメモリ・保存しないgetPost は関所。ファイルを読み、status を見る。本番かつ draft なら例外。置き場所ではない。ページ / generateMetadata は例外を拾うだけ。判定は持たない。

ページ側 / generateMetadata は、getPost が投げた例外を拾って notFound()(やフォールバック)に倒すだけでよいです。

tsx
export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  let post;
  try {
    post = getPost(slug);  // draft はここで例外になる
  } catch {
    notFound();
  }
  return <BlogBody post={post} />;
}

別のやり方として、getPost は素通しにして ページ側で if (post.meta.status !== 'published') notFound() と個別チェックする手もある。ただし page と generateMetadata の両方に同じチェックを書くことになり、ページが増えるほど書き漏れやすい。


この記事は2026年4月時点の自分の理解で書いています。Next.js の generateStaticParams / dynamicParams の挙動は変化が速い領域なので、最新の公式ドキュメントも合わせて参照してください。