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.body は StringIO などの IO オブジェクトです。
read メソッドはファイルポインタ(カーソル)を動かしながらデータを読み込みます。そのため、一度読み込むとカーソルは末尾に移動したままになります。
つまり、コントローラのアクションで参照するコードに到達する前に、他で既に body を読んでしまっている 可能性が高いことがわかります。
(request.body.rewind を呼び出してから request.body.read すると意図通り動作しました)
2. Content-Type の影響
調査を進めると、今回エラーになっていたリクエストでは Header に Content-Type が指定されていませんでした。
POST されるデータは XML のため Content-Type は text/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 のパラメータパース処理で変更があります。
調査の詳細は後述しますが、このパラメータパース処理で変更前はカーソル位置が戻されていたが、変更後はカーソル位置を戻していないと考えられます。
そのため、これまでは 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 ストリームのカーソル位置を意識せずに安全に生データを取得できます。
補足: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 では読みっぱなし(カーソルは末尾)の状態がデフォルトの挙動となりつつあります。
実際に Rack のコードを確認すると、form_data? と判定された場合に io.read していますが、その後に rewind していないことがわかります。
2. Rails 8 でのパラメータ解析ロジックの変更
Rails 8 では、パラメータ解析のパフォーマンス改善を目的とした変更が含まれています。これに伴い、Rails 内部でリクエストボディを読みに行くタイミングや経路が変わりました。
特に今回のような Content-Type が指定されていない POST リクエストの場合、以下のような定義の違いが発生していました。
- Rails の定義:
Content-Typeがない場合はフォームデータとはみなさないA request body is also assumed to contain form-data when no content-type header is provided and the request_method is POST.
RubyDoc.info: Module: Rack::Request::Helpers – Documentation for rack (3.2.4) – RubyDoc.info - Rack の定義:
Content-Typeがなくても POST ならフォームデータとみなすA request body is not assumed to contain form-data when no Content-Type header is provided and the request_method is POST.
ActionDispatch::Request
Rails 8 の内部処理の過程で Rack 側のロジックが呼び出されるケースがあります(fallback_request_parameters内)。
ここで Rack の定義に従ってボディが読み込まれますが、前述の仕様により rewind されないまま 処理が戻ってきます。
その結果、コントローラーのアクションに到達した時点では、request.body のカーソルは既に末尾にあり、read すると空文字が返る状態になっていました。
これまで request.body.read で動いていたのは、Rack 2 が裏側で気を利かせて rewind してくれていただけ であり、Rails 8 (Rack 3) では request.raw_post を使うことが、より安全な実装と言えそうです。
まだ具体的にどういう順序、分岐で実行されているのか検証はしきれていないため、再現ケースの調査を詳細にしたいと思います。
まとめ
-
安全にリクエストボディを参照したいなら
request.raw_postを使おう-
request.body.readはステートフル(カーソル位置依存)なので、不用意に使うとバグの温床になる。
-
-
Content-Type なしの挙動に注意
- 指定がない場合、意図しないタイミングで body が読み込まれることがある。
参考リンク
- Rails 5でrequest.body.readを複数回呼ぶとnilが返ってくる | fukata.dev
- Rails の Controller の params ってどうやって生成されているの? - hatappi.blog
-
With Sinatra 4/Rack3, now requires "request.body.rewind"
- Sinatra 4/Rack3 での類似問題
宣伝
弊社リーナーで 2025年に頑張ったことを話すドヤ LT 会をやるので東京、名古屋近郊の方はぜひお越しください!
Discussion