0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

"Body has already been read" を理解する

0
Posted at

背景

最近私は意識してVibeCodingをしながら知識を貯めるように心がけています。調べるのもAIが早いので、ソースを提示してもらって正確な情報を入れることで、自身のプロンプトの質の向上と、AIによる嘘を見抜く力を身に着けるためです。

その一環として"Body has already been read"というエラーを起こしてしまったので、基礎的な部分を見直しつつ、解決策を考えてみました。

問題の概要

Node.js の Web フレームワークで以下のエラーが発生しました:

TypeError: Body is unusable: Body has already been read
at consumeBody (node:internal/deps/undici/undici:5712:15)
at _Response.arrayBuffer (node:internal/deps/undici/undici:5657:18)

このエラーは、HTTP リクエストボディを複数回読み取ろうとした際に発生する一般的な Web 開発の問題です。

HTTP ストリームとボディ消費の仕組み

1. HTTP リクエストボディの本質

HTTP リクエストボディは ReadableStream として実装されており、以下の重要な特性を持ちます:

WHATWG Streams Standard による定義 ¹

"A given readable or writable stream only has at most one reader or writer at a time. We say in this case the stream is locked, and that the reader or writer is active."

この仕様により、ストリームは以下の制約を持ちます:

  1. 単一消費原則: ストリームは一度だけ読み取り可能
  2. ロック機構: 読み取り中はストリームがロックされ、他の読み取り操作を阻止
  3. 不可逆性: 一度消費されたストリームは再利用不可

※WHATWG (Web Hypertext Application Technology Working Group) はウェブ標準の保守や開発を行うためのコミュニティで、DOM、Fetch、HTML などを扱っています。Apple、Mozilla、Opera の従業員たちによって 2004 年に設立されました。

なぜ単一読み取り制約が存在するのか

この制約は技術的・セキュリティ的な理由から設計されています:

1. メモリ効率とセキュリティ防御

HTTP ボディをメモリ上に完全保存してから複数読み取りを許可する方式は、メモリ消費攻撃(DoS)の温床となります ⁴:

  • 攻撃者が大容量データを送信してサーバーメモリを枯渇させる
  • 複数の並行リクエストでリソース消費を倍増させる
  • バッファオーバーフロー攻撃の足がかりとなる

2. ストリーミング処理の実現

単一読み取り制約により、以下の効率的処理が可能になります:

  • 逐次処理: データを順次処理し、処理済み部分を即座にメモリから削除
  • バックプレッシャー制御: 処理能力に応じたデータ流量制御
  • 低レイテンシ: 全データ受信完了を待たずに処理開始可能

3. 歴史的経緯と設計思想

WHATWG の設計思想では:

"Features that combine multiple responses into one logical resource are historically a source of security bugs"²

複数のレスポンスや読み取り操作を組み合わせる機能は、歴史的にセキュリティバグの原因となってきたため、単純かつ安全な API 設計が採用されました。

WHATWG Fetch Standard による実装 ²

"Bodies can only be read once. Cloning a body creates a 'tee' of the original stream, allowing two independent readers."

この仕様により、HTTP ボディの読み取りは一度限りの操作となります。

2. Node.js undici における実装

undici のボディ消費メカニズム ³

Node.js の undici HTTP クライアントでは、以下のボディ消費メソッドが提供されています:

  • .text(): UTF-8 文字列として消費
  • .json(): JSON オブジェクトとして消費
  • .arrayBuffer(): ArrayBuffer として消費
  • .formData(): FormData として消費

重要な制約として:

"Once a mixin has been called then the body cannot be reused, thus calling additional mixins on .body, e.g. .body.json(); .body.text() will result in an error TypeError: unusable being thrown."

これは、一つのボディに対して複数の消費メソッドを呼び出すことができないことを意味します。例えば:

// ❌ エラーが発生するパターン
const response = await fetch("/api/data");
const jsonData = await response.json(); // 最初の消費
const textData = await response.text(); // TypeError: unusable

// ✅ 正しいパターン
const response = await fetch("/api/data");
const jsonData = await response.json(); // 一度だけ消費

この制約により、ミドルウェアとルートハンドラーで重複してボディを読み取ろうとすると "Body has already been read" エラーが発生します。

原因の分析

1. 実際に発生した問題のフロー

本件で発生した "Body has already been read" エラーの実際の流れは以下の通りです:

  1. ルートハンドラーawait c.req.json() を呼び出し、リクエストボディを消費
  2. 処理中に何らかのエラーが発生
  3. ロガーミドルウェアがエラー情報取得のためリクエストボディの読み取りを試行
  4. 既にボディが消費済みのため "Body has already been read" エラーが発生

2. 共通ミドルウェアでのボディ読み取りが抱える根本問題

この問題は、以下のような共通処理でリクエストボディを読み取る必要があるケースで頻繁に発生します:

  • ロガーミドルウェア(リクエスト内容のログ出力)
  • 認証ミドルウェア(ボディの検証)
  • レート制限ミドルウェア(ボディサイズの確認)
  • エラーハンドリングミドルウェア(エラー詳細情報の取得)

これらの共通処理は、ルートハンドラーがボディを消費した後に実行される可能性があるため、ストリーム重複読み取りエラーが発生しやすくなります。

3. エラーが発生した具体的なケース

実際に発生しうるケースを考えていきましょう。

問題のあるルート実装 (/routes/auth.ts)

// /auth/updateUser - Line 237
const { name } = await c.req.json(); // ボディを消費

// /auth/update-password - Line 282
const { newPassword } = await c.req.json(); // ボディを消費

これらのルートはボディを直接消費し、エラー発生時にロガーミドルウェアが再度ボディを読み取ろうとしてエラーが発生しました。

ロガーミドルウェアの期待動作 (/middleware/logger.ts)

// リクエストボディを取得(ストリームを消費しないよう注意)
const existingBody = c.get("requestBody");
if (existingBody) {
  requestParams.requestBody = existingBody as Record<string, unknown>;
}
// リクエストボディを直接読み込まない(ストリーム消費を避ける)

ロガーミドルウェアは事前にコンテキストに保存された requestBody を期待していますが、上記のルートでは保存処理が行われていませんでした。

解決方法

1. 事前ボディ読み取りミドルウェア

共通ミドルウェアでリクエストボディを読み取る必要がある場合、以下のアプローチで解決できます:

// ボディを事前に読み取り、コンテキストに保存するミドルウェア
const bodyParsingMiddleware = async (c: Context, next: () => Promise<void>) => {
  const contentType = c.req.header("content-type");

  if (contentType?.includes("application/json")) {
    try {
      // ボディを一度だけ読み取り
      const body = await c.req.json();

      // コンテキストに保存(ロガーミドルウェアで使用)
      c.set("requestBody", body);

      // ルートハンドラー用のヘルパー関数を提供
      c.set("getParsedBody", () => body);
    } catch (error) {
      // JSONパースエラーの場合はそのまま通す
      console.warn("Failed to parse JSON body:", error);
    }
  }

  await next();
};

// ルート実装
app.use("/auth/*", bodyParsingMiddleware);

app.put("/auth/updateUser", async (c) => {
  const userId = c.get("uid");

  if (!userId) {
    throw new HTTPException(401, { message: "認証が必要です" });
  }

  try {
    // 事前に解析されたボディを取得(ストリーム読み取りなし)
    const getParsedBody = c.get("getParsedBody");
    const { name } = getParsedBody();

    // ... 残りの処理
  } catch (error) {
    // エラーハンドリング
    // ここでロガーミドルウェアが c.get('requestBody') でボディにアクセス可能
  }
});

2. 条件付き

// サイズとルートに基づく条件付き処理
const conditionalBodyMiddleware = async (c: Context, next: () => Promise<void>) => {
  const path = c.req.path;
  
  // 特定ルートのみキャッシュ
  if (path.startsWith("/auth/")) {
    const body = await c.req.json();
    c.set("requestBody", body);
    c.set("getParsedBody", () => body);
  }
  
  await next();
};

メリット

  1. ストリーム消費の一元化: ボディの読み取りを一箇所で実行
  2. 共通ミドルウェア対応: ロガーやエラーハンドラーが安全にボディにアクセス可能
  3. 既存コードの最小変更: ルートハンドラーの変更を最小限に抑制
  4. 柔軟性: 必要に応じて異なる形式でのボディアクセスが可能

デメリット

  1. メモリ消費の増大: 大容量ボディでメモリ使用量が増加
  2. DoS攻撃脆弱性: 巨大ペイロード攻撃でサーバーメモリを枯渇させる攻撃経路を提供

デメリットも考えるとミドルウェアによる事前ボディ読み取りは万能な解決策ではなく、loggerにbodyを含めるのを特定のAPIに絞ったり、Body情報は拾わないなどの選択肢を取ることを推奨します。

処理の流れ

// 1. ミドルウェアがボディを一度だけ読み取り
const body = await c.req.json();

// 2. コンテキストに保存
c.set("requestBody", body);

// 3. ルートハンドラーでコンテキストから取得
const body = c.get("getParsedBody")(); // ストリーム読み取りなし

// 4. エラー時もロガーがコンテキストから取得
const body = c.get("requestBody"); // ストリーム読み取りなし

まとめ

最終的に今回問題だったのは、ロガーの整理を最後に行ったことで後付け実装となったことと、エラーハンドリング周りのテストが不十分であったことが挙げられます。

幸い、テスト期間に発覚したため大きな問題にはなりませんでしたが、実装者レベルで気づける部分は無限に反省点が出てきます。

実務ではもちろんスピード感が重要視されますが、それ以上に最初の設計をしっかり固めておくことの大切さを改めて実感しました。

参考文献

  1. WHATWG Streams Standard: Streams Standard - ReadableStream の仕様とロック機構に関する定義
  2. WHATWG Fetch Standard: Fetch Standard - HTTP ボディの消費とクローニングに関する仕様
  3. Node.js undici Documentation: GitHub - nodejs/undici - Node.js HTTP クライアントの実装詳細
  4. Resource Exhaustion Attack - Wikipedia: Resource exhaustion attack - メモリ消費攻撃に関する解説
  5. MDN Web API Documentation: ReadableStream - Web APIs | MDN - ReadableStream API の実装詳細
  6. RFC 9110 - HTTP Semantics: RFC 9110 - HTTP メッセージの構造とセマンティクス
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?