Next.js での安全な画像アップロード

Next.js のサンプルにおけるアップロードの流れの図。ブラウザー、ルートハンドラー、readForm()、lib/gallery.ts のチェック、uploads のルートがあり、失敗時は 400 または 413 が返される

Next.js は Server Actions をいくつかの方法で保護していますが、ルートハンドラーにはその保護がありません。そして、RichTextEditor のアップロードが送られるのはルートハンドラーです。このページでは、Next.js のサンプルが自分で追加している対策を説明します。クロスサイトの POST に対する Origin のチェック、書き込み前のファイルのチェック、そして自分のドメインをファイルホスティングにしてしまわずにアップロードを配信するルートです。

ルートハンドラーには Origin のチェックがない

Next.js のデータセキュリティのガイドによると、Server Actions は Origin ヘッダーを Host(または X-Forwarded-Host)と比較し、異なる場合はリクエストを中止します。ルートハンドラーはそのような処理をしません。チェックを追加する前に、Origin: https://evil.example を付けて POST /rte/gallery を送信したところ、サンプルはフォルダーを作成し、200 を返しました。ログインに Cookie を使っている場合、ほかのどのサイトでも、サインイン中のユーザーのブラウザーに同じことをさせられます。現在のサンプルはこれを拒否します。

TypeScript · lib/gallery.ts
// Next.js checks the Origin of Server Actions, not of route handlers. A browser sends Origin
// with these POSTs, so refuse one from another site. Requests without Origin (curl) pass.
export function checkOrigin(request: Request): void {
  const origin = request.headers.get("origin");
  if (!origin) return;
  const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
  let originHost: string | null = null;
  try {
    originHost = new URL(origin).host;
  } catch {}
  if (originHost !== host) throw new GalleryError(403, "Cross-site request refused.");
}

どちらの POST ハンドラーも、本文を読み込む前に、まず checkOrigin(request) を呼び出します。Chromium がエディター自身のアップロードに Origin: http://localhost:5102 を付けて送信することを確認しているので、これらは通過します。curl やサーバー間のコードのように Origin がまったくないリクエストも通過します。これはクロスサイトのブラウザーリクエストではなく、ログインのチェックは引き続き適用されます。サンドボックス化されたページが送信する Origin: null は拒否されます。ホスト名を変更するリバースプロキシの背後に置く場合は、プロキシが X-Forwarded-Host を設定するようにしてください。そうしないと、このチェックは自分のページからのリクエストも拒否してしまいます。

書き込む前にチェックすること

ページ上部の図は、アップロードの処理順序を示しています。Origin のチェックの後、readForm() が本文のサイズを制限し(Next.js での画像アップロードのサイズ制限を参照してください)、その後 lib/gallery.ts がファイルをチェックします。

TypeScript · lib/gallery.ts
// Allowed extensions, each with a check of the file's first bytes (its signature).
const SIGNATURES: Record<string, (bytes: Buffer) => boolean> = {
  ".jpg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".jpeg": (b) => hex(b, 0, 3) === "ffd8ff",
  ".png": (b) => hex(b, 0, 8) === "89504e470d0a1a0a",
  ".gif": (b) => ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a",
  ".webp": (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP",
};
const hex = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("hex");
const ascii = (bytes: Buffer, start: number, end: number) => bytes.subarray(start, end).toString("latin1");

// True for "chair.jpg", false for ".chair.jpg", "chair.svg" or "chair".
export function isImageName(name: string): boolean {
  return !name.startsWith(".") && Object.hasOwn(SIGNATURES, extensionOf(name));
}

// A folder or file name: 1-64 letters, digits, space, - _ . and not starting with a dot.
// Also refused: a trailing dot or space (Windows drops them, so "a." would become "a")
// and Windows device names such as CON or NUL.
export function isValidName(name: unknown): name is string {
  return (
    typeof name === "string" &&
    /^[A-Za-z0-9 _.-]{1,64}$/.test(name) &&
    !name.startsWith(".") &&
    !/[. ]$/.test(name) &&
    !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(name)
  );
}
  • 拡張子は許可リストにあるもの(.jpg、.jpeg、.png、.gif、.webp)でなければなりません。SVG はスクリプトを含められるため除外しています。
  • 先頭バイトは拡張子と一致しなければなりません。formData() から得られる File.type は一切使いません。これはブラウザーが申告した値にすぎないからです。
  • フォルダーは、Node.js のギャラリーフォルダーと同様に、実際のパスが uploads/ 内にある有効な仮想パスでなければなりません。
  • 保存名は生成したもので、writeFile は "wx" 付きで実行されるため、上書きすることはありません。

シグネチャのチェックで証明できるのはファイルの始まり方であり、それが画像であることではありません。JPEG のバイト列で始まり、その後に HTML が続くファイルは通過します。だからこそ、ファイルの配信方法が重要になります。

public/ を使わずにアップロードを配信する

next start はビルド時にあったファイルしか配信しないため、アップロードを public/ に置くことはできません。代わりに app/uploads/[...path]/route.ts が配信します。これにより、public/ では設けられないルールを置く場所ができます。

TypeScript · lib/gallery.ts
// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
  const name = parts.at(-1) ?? "";
  if (!isImageName(name) || /[\\/\0]/.test(name)) {
    throw new GalleryError(404, "Not found.");
  }
  const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
    throw new GalleryError(404, "Not found.");
  });
  const file = path.join(dir, name);
  const info = await lstat(file).catch(() => null);
  if (!info || !info.isFile()) {
    throw new GalleryError(404, "Not found.");
  }
  return file;
}

配信するのは画像の名前だけで、しかも同じパスのチェックを通過したフォルダーからだけです。stray.html、.hidden.png、.secret/a.png、..%2f..%2fpackage.png、..%5cpackage.png、a%00.png を試したところ、すべて 404 になりました。Next.js はルートに渡す前に [...path] のセグメントをデコードするので、%2f はセグメント内の実際の / として届き、名前のチェックで拒否されます。uploads/ に置いた Windows のジャンクションも拒否されました。チェックが実際のパスを比較しているためです。すべての画像は X-Content-Type-Options: nosniff 付きで送信されるため、ブラウザーは image/* の型を維持し、ファイルを HTML として実行することはありません。

3 つのハンドラーはすべて runtime = "nodejs" をエクスポートしています。これらは node:fs を必要とし、Next.js の Edge ランタイムのドキュメントには、そこではファイルシステムの読み書きができないとはっきり書かれています。

サンプルが対応していないこと

  • アップロードできるユーザー。サンプルにはログインの仕組みがありません。ユーザーごとのルートを置く場所は lib/root-for.ts で、共有ギャラリーはそのためにすでに uploads/users/ をアクセスできないようにしています。
  • 画像を閲覧できるユーザー。/uploads/... は公開されています。画像を非公開にする場合は、uploads のルートにセッションのチェックを追加してください。
  • 容量の上限と、完全な画像であることを確認する必要がある場合の、ライブラリによる画像のデコード。

テストする

このガイドのサンプルプロジェクトを npm run build と npm start で起動し、以下を実行します。

Shell
curl -H "Origin: https://evil.example" -F action=create-folder -F folder= -F name=x http://localhost:5102/rte/gallery
{"error":"Cross-site request refused."}

printf '<?php echo 1; ?>' > fake.jpg
curl -F "file=@fake.jpg" http://localhost:5102/rte/upload
{"error":"The file content is not a valid JPG image."}

curl -F "file=@photo.jpg;filename=x.svg" http://localhost:5102/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}

ステータスは順に 403、400、400 です。ギャラリーダイアログでは、拒否されたファイルについて「Upload failed for <name>.」と表示されます。ダイアログはサーバーのメッセージを表示しません。

ほかのプラットフォーム向けの同じガイド

JavaScript · PHP · ASP.NET Core · Razor Pages · MVC 5 · Web Forms

次に読む