公開APIでは、入力項目の文字数だけでなく、リクエスト本文そのもののサイズにも上限が必要です。

Hakuria Systemsの問い合わせAPIでは、当初、本文をすべて読み込んだ後でサイズを判定していました。しかし、この順序では上限を超えたデータが先にメモリへ載るため、リソース保護として十分ではありません。そこで、Nuxt 4のServer APIを構成するH3で、ストリームを読みながら実測バイト数を制限する方式へ変更しました。

この記事では、その実装と回帰不具合から得た知見を一般化して紹介します。本番環境の具体的な上限値ではなく、設計判断と検証方法に焦点を当てます。

先に結論

APIのリクエスト本文サイズは、次の方針で制限します。

  1. Content-Lengthがある場合は、明らかな超過を読み込み前に拒否する
  2. ヘッダーの有無にかかわらず、ストリームから読んだ実測バイト数を数える
  3. 上限を超えた時点で、それまで保持したチャンクを破棄する
  4. 本文を読む処理は、MiddlewareとAPIのどちらか一方に集約する
  5. サイズ確認後にだけ、文字列化、JSON解析、項目検証へ進む

Content-Lengthは早期判定には役立ちますが、最終的な防御境界にはしません。アプリケーションが実際に受け取ったバイト数を基準にすることが重要です。

入力項目の文字数制限だけでは足りない

たとえば、お問い合わせ内容を数千文字までに制限していても、その検証を行うには先にHTTP本文を読み、JSONとして解析する必要があります。非常に大きな本文が送られた場合、項目検証へ到達する前にメモリとCPUを消費します。

次のように、全量を読み込んでから判定する実装では、サイズ超過を検知した時点ですでに本文を保持しています。

const rawBody = await readRawBody(event, false)

if ((rawBody?.byteLength ?? 0) > maxBytes) {
  throw createError({ statusCode: 413 })
}

この判定は「大きな本文を後続処理へ渡さない」ためには有効です。一方で、「大きな本文をメモリへ蓄積しない」という目的は満たせません。

本文サイズ制限は、入力値検証ではなく、リクエスト1件あたりのリソース上限を決める処理として設計する必要があります。

Content-Lengthだけに頼れない理由

Content-Lengthが上限を超えていれば、本文を読む前に413 Payload Too Largeを返せます。不要な読み込みを避けられるため、この早期判定は残すべきです。

ただし、Content-Lengthが常に存在するとは限りません。ストリーミングや転送方式、実行基盤までの経路によっては、アプリケーションへ届く時点でヘッダーがないケースもあります。また、ヘッダーは本文を実測した結果ではなく、リクエストに付随するメタデータです。

そのため、役割を次のように分けます。

  • Content-Length:読み込み前に拒否するための早期判定
  • 実測バイト数:アプリケーションが保持する量を保証する最終判定

早期判定と実測判定は、どちらか一方を選ぶものではありません。両方を組み合わせることで、効率と確実性を両立できます。

H3のリクエストストリームを読みながら制限する

H3では、getRequestWebStreamを使ってリクエスト本文のストリームを取得できます。以下は、実装の要点を簡略化した例です。

import { getRequestWebStream } from "h3"
import type { H3Event } from "h3"

type WebAwareH3Event = H3Event & {
  web?: { request?: Request }
}

export async function readLimitedBody(event: WebAwareH3Event, maxBytes: number) {
  const stream = getRequestWebStream(event)

  if (!stream) {
    return { tooLarge: false, body: new Uint8Array() }
  }

  const reader = stream.getReader()
  const chunks: Uint8Array[] = []
  let totalBytes = 0
  let tooLarge = false

  try {
    while (true) {
      const { done, value } = await reader.read()

      if (done) break
      if (tooLarge) continue

      const chunk = value instanceof Uint8Array
        ? value
        : new Uint8Array(value)

      if (totalBytes + chunk.byteLength > maxBytes) {
        chunks.length = 0
        tooLarge = true

        if (event.web?.request) {
          await reader.cancel("request body too large").catch(() => undefined)
          break
        }

        // Node.js側では、残りを保持せず読み捨てる
        continue
      }

      totalBytes += chunk.byteLength
      chunks.push(chunk)
    }
  } finally {
    reader.releaseLock()
  }

  if (tooLarge) {
    return { tooLarge: true, body: new Uint8Array() }
  }

  const body = new Uint8Array(totalBytes)
  let offset = 0

  for (const chunk of chunks) {
    body.set(chunk, offset)
    offset += chunk.byteLength
  }

  return { tooLarge: false, body }
}

ポイントは、各チャンクを保持する前にtotalBytes + chunk.byteLengthを確認することです。上限を超えた場合は、すでに保持しているチャンクも破棄します。

Cloudflare WorkersのようなWebランタイムでは、超過後のストリームをキャンセルできます。Node.jsでも動かす場合は、接続上の扱いを考慮し、残りをメモリへ保持せず読み捨てる方法があります。どの動作が適切かは、採用するランタイムとアダプターで確認が必要です。

日本語JSONは文字数ではなくバイト数で考える

JavaScriptの文字列長と、HTTP本文のバイト数は同じではありません。日本語を含むUTF-8のJSONでは、見た目の文字数よりバイト数が大きくなります。

また、ストリームのチャンク境界が文字の境界と一致する保証もありません。各チャンクを別々に文字列へ変換すると、マルチバイト文字が分割された場合に文字化けする可能性があります。

今回の実装では、次の順序にしました。

  1. Uint8Arrayのままバイト数を数える
  2. 上限内のチャンクだけを結合する
  3. 結合後にTextDecoderで一度だけUTF-8文字列へ変換する
  4. JSON.parseと項目検証を行う

これにより、サイズ判定と文字コード処理の責務を分けられます。

本文はMiddlewareとAPIで二重に読まない

ストリーム方式へ変更した後、通常サイズのお問い合わせまで送信できなくなる回帰不具合が発生しました。

原因は、本文サイズを制限するServer Middlewareと、問い合わせAPIの両方で同じリクエスト本文を読んでいたことです。Middlewareが先にストリームを消費したため、API側で再度読んだ本文は0バイトとなり、入力エラーになっていました。

リクエスト本文は、原則として一度しか消費できません。H3の公式ドキュメントでも、本文を先に読むとストリームがロックされ、後続処理で再利用できなくなる点が説明されています。

修正では、本文を読む責務をAPI側へ集約し、Middlewareから本文読込処理を削除しました。共通Middlewareで本文を扱う設計にする場合は、読み取ったバイト列を後続へ渡す契約を明示し、API側が再読込しないように統一する必要があります。

便利な共通化でも、ストリームの所有者が曖昧になると不具合につながります。「誰が本文を読むのか」を設計上の責務として決めることが重要です。

WAF・Rate Limit・Turnstileとの役割の違い

本文サイズ制限だけで、公開APIの安全性が完成するわけではありません。それぞれが守る対象は異なります。

対策主に制限するもの本文サイズ制限の代わりになるか
WAF既知の攻撃パターンやエッジ側のポリシーならない
Rate Limit一定時間あたりのリクエスト回数ならない
Turnstile自動化されたアクセスやボットならない
本文サイズ制限1リクエストあたりに受け取るデータ量対象そのもの

たとえばRate Limitが正しくても、許可された1リクエストが大きければ、アプリケーション側の負荷は増えます。反対に本文サイズが小さくても、大量のリクエストを許せば別の負荷が発生します。

Hakuria Systemsの問い合わせフォームでは、これらを置き換えず、層を分けて組み合わせています。

回帰テストで確認したいケース

サイズ制限は境界値で壊れやすいため、正常系だけでは不十分です。少なくとも次のケースを確認します。

  • 上限と同じバイト数を受け入れる
  • 上限を1バイト超えた本文を拒否する
  • Content-Lengthがなくても、実測超過を拒否する
  • 超過時に保持済みの本文を返さない
  • 通常サイズの日本語JSONを壊さず解析できる
  • 対応対象のWebランタイムでストリームをキャンセルできる
  • Node.jsも対象なら、超過後のデータを保持せず処理できる
  • Middlewareが対象APIの本文を先に消費していない

特に最後のテストは、今回の回帰不具合を受けて追加したものです。ヘルパー関数単体の正しさだけでなく、アプリケーション全体で読込責務が重複していないことも固定します。

実装時のチェックリスト

  • 本文を全量取得する前に上限を判定できているか
  • Content-Lengthを早期判定に使い、実測判定も残しているか
  • 文字数ではなく生のバイト数を数えているか
  • 超過時に保持済みチャンクを破棄しているか
  • 超過時は413 Payload Too Largeとして扱っているか
  • 本文を読む処理が複数の層に分散していないか
  • サイズ確認後にJSON解析と入力値検証を行っているか
  • 実行する各ランタイムで境界値テストを行っているか

まとめ

リクエスト本文サイズの上限は、単なる入力チェックではなく、APIが1リクエストに使うリソースの境界です。

Content-Lengthによる早期拒否と、ストリーム読込中の実測バイト制限を組み合わせることで、大きな本文を全量保持する前に処理を止められます。同時に、本文を読む責務を一か所へ集約しなければ、正常なリクエストまで壊す可能性があります。

今回の改善では、制限機能そのものだけでなく、その後に発生した二重読込の不具合までテストへ反映しました。安全性のための変更ほど、正常系を守る回帰テストとセットで進めることが大切です。

参考資料

Hakuria Systemsでは、こうしたAPI設計やセキュリティを含むシステム開発を支援しています。ご相談はお問い合わせフォームからご連絡ください。