Next.js での画像アップロードのサイズ制限

Next.js で大きなアップロードを拒否できるレイヤーの図。ギャラリーダイアログ、リバースプロキシ、制限のない request.formData()、readForm()、4,000,000 バイトのチェック

Next.js のルートハンドラーでは、アップロードを読み取る方法としてドキュメントに記載されているのが request.formData() ですが、Next.js にはそのアップロードのサイズを制限する仕組みがありません。このページでは、当社が計測した結果と、サンプルが代わりに使っている小さな readForm() 関数、そしてコードより先に大きな画像を拒否できるほかのレイヤーを紹介します。

formData() はすべてを読み込む

await request.formData() だけを行う一時的なルートハンドラーを追加し、50 MB のファイルを送信しました。レスポンスは fileSize: 50000000 付きの 200 で、その 1 回のリクエストの間に、開発サーバーのメモリ(RSS)は約 175 MB 増えました。ルートハンドラーのドキュメントは formData() について説明していますが、サイズ制限には触れていません。Pages Router の API ルートとは異なり、ルートハンドラーには bodyParser の設定が不要だと記載されています。つまり、設定する値はありません。制限はすべて自分のコードで設ける必要があります。

解析する前にバイト数を数える

lib/gallery.ts の readForm() は、申告された Content-Length が制限を超えていれば拒否し、そうでなければ本文のストリームを読み込み、制限を超えた時点で読み込みを止めます。制限内の本文だけが、Response を通じて標準のパーサーに渡されます。

TypeScript · lib/gallery.ts
// request.formData() reads the whole body into memory, however large. This reads at most
// MAX_BYTES plus room for the other fields, answers 413 above that, then parses it.
export async function readForm(request: Request): Promise<FormData> {
  const limit = MAX_BYTES + 64 * 1024;
  if (Number(request.headers.get("content-length")) > limit) {
    throw new GalleryError(413, TOO_BIG);
  }
  const chunks: Uint8Array[] = [];
  let size = 0;
  const reader = request.body?.getReader();
  while (reader) {
    const { done, value } = await reader.read();
    if (done) break;
    size += value.length;
    if (size > limit) {
      await reader.cancel();
      throw new GalleryError(413, TOO_BIG);
    }
    chunks.push(value);
  }
  const body = new Response(Buffer.concat(chunks), {
    headers: { "content-type": request.headers.get("content-type") ?? "" },
  });
  try {
    return await body.formData();
  } catch {
    throw new GalleryError(400, "Expected a multipart/form-data request.");
  }
}

制限は、4,000,000 バイトのファイルに、フォームのフィールドとその周りのマルチパートヘッダーのための 64 KB を加えた値です。Content-Length のチェックだけでは十分ではありません。チャンク形式のリクエストには長さがないからです。実際にそうしたリクエストを送信したところ、ストリームのカウントがそれを捕捉しました。next dev と next start のどちらでも、20 MB の本文のうちクライアントが約 4.9 MB を送信した時点で 413 が返り、クライアント側で接続エラーは発生しませんでした。reader.cancel() が読み込みを止めるため、残りがバッファーに溜まることはありません。比較すると、multer を使った Express は、20 MB すべてを読み込んでからレスポンスを返しました。

解析の後、saveImage() がファイル自体をチェックします。ちょうど 4,000,000 バイトの境界を判定するのはこのチェックです。そのサイズのファイルは保存され、4,000,001 バイトのファイルは 413 で拒否されました。

ほかのレイヤー

  1. ギャラリーダイアログ。エディターのオプションで maxUploadFileSize: 4000000 を指定すると、ダイアログはそれより大きなファイルを何も送信する前に拒否し、「big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB.」と表示します。サーバーはこれに頼ることはできません。
  2. リバースプロキシ。nginx のドキュメントでは、client_max_body_size の既定値は 1m とされています。既定設定の nginx の背後では、2 MB の写真は nginx 独自の 413 ページを受け取り、Next.js には届きません。
  3. proxy ファイル(以前はミドルウェアと呼ばれていたもの)。Next.js のドキュメントによると、これを使う場合、Next.js はプロキシとルートの両方が読み取れるようにリクエストの本文をバッファーに格納します。上限は experimental.proxyClientMaxBodySize で、既定値は 10 MB です。これを超えると、本文は上限で切り詰められ、警告がログに記録されます。サンプルには proxy ファイルがないため、この動作はテストしていません。この説明どおりなら、proxy ファイルを追加すると、10 MB を超える本文は拒否されるのではなく、途中で切れた状態でルートハンドラーに届きます。
  4. Server Actions には独自の serverActions.bodySizeLimit があり、ドキュメントによると既定値は 1 MB です。この設定は Server Actions 用です。ルートハンドラーには何の効果もなく、エディターのアップロードが送られるのはルートハンドラーです。

ページ上部の図は、その順序を示しています。サンプルの 413 がユーザーに返されるようにするには、それより前のすべてのレイヤーで 4,000,000 バイトより少し大きいサイズまで許可しておく必要があります。

ブラウザーに返るもの

サンプルが拒否する場合は、すべて {"error":"The image is larger than 4,000,000 bytes."} 付きの HTTP 413 になります。ギャラリーダイアログは、アップロードが失敗すると常に独自のテキスト「Upload failed for <name>.」を表示します。そのため、ダイアログ自体のサイズチェックを有効にしている場合、ユーザーが目にするのは通常、ダイアログのメッセージのほうです。

テストする

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

Shell
head -c 4500000 /dev/zero > big.jpg
curl -F "file=@big.jpg" http://localhost:5102/rte/upload
{"error":"The image is larger than 4,000,000 bytes."}

ステータスは 413 です。チャンク形式の場合を試すには、長さを指定せずに本文を送信します。たとえば、fetch で ReadableStream を本文に指定します(duplex: "half")。レスポンスは同じ 413 で、アップロードが終わる前に返ってきます。

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

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

次に読む