🧁

Rails で安全にリクエストボディを参照するために request.raw_post を使おう

に公開

はじめに

プロダクトの Rails を 8.0 にアップデートした時の話です。
メジャーバージョンアップということで入念に動作確認を行っていたつもりでしたが、本番環境へのデプロイ後、別システムからの Webhook が正しく処理されない という事象が発生しました。

調査の結果、Rails における request.body.read の挙動と、Content-Typeの関係性が絡み合った問題であり、request.raw_post を使うべきだと分かったため知見として共有します。

起きたこと

背景

我々のシステムには、別システムから POST リクエスト(Webhook)で XMLデータを受け取り、そのデータをもとに情報を反映するエンドポイントが存在します。
コントローラ内では、リクエストボディ内の XML のデータを参照して処理を行っていました。

障害の内容

Rails 8 へのアップデート以降、この Webhook を受け取る処理で POST リクエストのデータが空(空文字 "")であると判定され、情報の反映に失敗するようになりました。
コードを確認すると、リクエストボディを取得するために request.body.read を使用していました。

原因調査:なぜ body が空になるのか?

1. request.body.read の仕様

まず Rails の request.bodyStringIO などの IO オブジェクトです。
read メソッドはファイルポインタ(カーソル)を動かしながらデータを読み込みます。そのため、一度読み込むとカーソルは末尾に移動したままになります。

つまり、コントローラのアクションで参照するコードに到達する前に、他で既に body を読んでしまっている 可能性が高いことがわかります。
(request.body.rewind を呼び出してから request.body.read すると意図通り動作しました)

https://github.com/rails/rails/blob/v8.1.1/actionpack/lib/action_dispatch/http/request.rb#L362-L369

2. Content-Type の影響

調査を進めると、今回エラーになっていたリクエストでは Header に Content-Type が指定されていませんでした

POST されるデータは XML のため Content-Typetext/xml や、application/xml を期待します。
これらを指定する場合では再現しませんでしたが、 application/x-www-form-urlencoded を指定した場合は再現しました。

Content-Type が存在しない場合、もしくは application/x-www-form-urlencodedの場合に発生すると考えられます。

3. Rails 8 アップデートとの関係

アップデートの diff を確認すると actionpack/lib/action_dispatch/http/request.rb の L412 のパラメータパース処理で変更があります。
https://github.com/rails/rails/compare/v7.2.2...v8.0.0#diff-60b77e427ea7ba142faa477fac10b8d0134cede4e35a3b1953c425200fadf1acR412

調査の詳細は後述しますが、このパラメータパース処理で変更前はカーソル位置が戻されていたが、変更後はカーソル位置を戻していないと考えられます。
そのため、これまでは request.body.read でも問題になっていなかったが、アップデートを機にこの潜在的な問題が顕在化しました。

解決策: request.raw_post を使う

この問題に対して安全に実装するためのベストプラクティスは request.raw_post を使うこと です。

修正内容

# 修正前
body = request.body.read

# 修正後
body = request.raw_post

なぜ request.raw_post なのか?

request.raw_post の実装を見ると、必要に応じて rewind(巻き戻し)を行ったり、キャッシュされたデータを返したりしてくれるため、IO ストリームのカーソル位置を意識せずに安全に生データを取得できます。

https://github.com/rails/rails/blob/v8.1.1/actionpack/lib/action_dispatch/http/request.rb#L353-L358

補足:rewind する方法

巨大なファイルを扱うためストリームとして処理したい(メモリに展開したくない)場合は、読み込む前に必ず rewind した上で request.body.read を使いましょう。

request.body.rewind # カーソルを先頭に戻す
body = request.body.read(1000)

余談: アップデートによる変更の詳細調査

今回の事象は、単なるバグというよりは Rails 8 の内部実装の変更 と Rack 3 の仕様変更が組み合わさった結果発生したものです。

調査の結果、以下の2点が主な要因であることがわかりました。

1. Rack 3 では巻き戻し (rewind)をしなくなった

Rails 8 は Rack 3 に依存しています。Rack 3 ではパフォーマンスやストリーミング対応の観点から、 リクエストボディを読み込んだ後に自動的に rewind するという挙動が保証されなくなりました。

Rack 2 時代は、パラメータ解析のために一度ボディを読み込んでも、Rack 側で親切にカーソルを先頭に戻してくれていました。しかし、Rack 3 では読みっぱなし(カーソルは末尾)の状態がデフォルトの挙動となりつつあります。

https://github.com/rack/rack/discussions/1972

実際に Rack のコードを確認すると、form_data? と判定された場合に io.read していますが、その後に rewind していないことがわかります。
https://github.com/rack/rack/blob/37ec310b5631abb62d119cf72136bec4deb160eb/lib/rack/request.rb#L521-L524

https://github.com/rack/rack/blob/37ec310b5631abb62d119cf72136bec4deb160eb/lib/rack/multipart/parser.rb#L296-L300

2. Rails 8 でのパラメータ解析ロジックの変更

Rails 8 では、パラメータ解析のパフォーマンス改善を目的とした変更が含まれています。これに伴い、Rails 内部でリクエストボディを読みに行くタイミングや経路が変わりました。

https://github.com/rails/rails/pull/53193

特に今回のような Content-Type が指定されていない POST リクエストの場合、以下のような定義の違いが発生していました。

https://github.com/rack/rack/blob/37ec310b5631abb62d119cf72136bec4deb160eb/lib/rack/request.rb#L486-L491

https://github.com/rails/rails/blob/dd8f7185faeca6ee968a6e9367f6d8601a83b8db/actionpack/lib/action_dispatch/http/request.rb#L372-L374

Rails 8 の内部処理の過程で Rack 側のロジックが呼び出されるケースがあります(fallback_request_parameters内)。

https://github.com/rails/rails/blob/dd8f7185faeca6ee968a6e9367f6d8601a83b8db/actionpack/lib/action_dispatch/http/request.rb#L413-L419

ここで Rack の定義に従ってボディが読み込まれますが、前述の仕様により rewind されないまま 処理が戻ってきます。

その結果、コントローラーのアクションに到達した時点では、request.body のカーソルは既に末尾にあり、read すると空文字が返る状態になっていました。

これまで request.body.read で動いていたのは、Rack 2 が裏側で気を利かせて rewind してくれていただけ であり、Rails 8 (Rack 3) では request.raw_post を使うことが、より安全な実装と言えそうです。

まだ具体的にどういう順序、分岐で実行されているのか検証はしきれていないため、再現ケースの調査を詳細にしたいと思います。

まとめ

  1. 安全にリクエストボディを参照したいなら request.raw_post を使おう
    • request.body.read はステートフル(カーソル位置依存)なので、不用意に使うとバグの温床になる。
  2. Content-Type なしの挙動に注意
    • 指定がない場合、意図しないタイミングで body が読み込まれることがある。

参考リンク

宣伝

弊社リーナーで 2025年に頑張ったことを話すドヤ LT 会をやるので東京、名古屋近郊の方はぜひお越しください!
https://leanertechnologies.connpass.com/event/376577/

リーナーテックブログ

Discussion