Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 8: strong parametersの新しいparams.expectの使い方(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

参考: 4.6 Strong Parameters -- Action Controller の概要 - Railsガイド
参考: Rails 8.0 API expect -- ActionController::Parameters -- APIドキュメントでも、従来のpermitrequireよりexpectが望ましいと書かれています。

参考: 8.0 Changelogのexpect -- ここにも詳しく書かれています。

Rails 8: strong parametersの新しいparams.expectの使い方(翻訳)

Cloud City DevelopmentにおけるRubyオープンソースへの継続的なサポートの一環として、最近Rails 8に新しく追加されたparams.expect機能(#51674)について晴れてここに発表いたします。

paramsからやってくる攻撃

Webアプリケーションのプログラマーなら誰しも、ユーザー入力を決して信用しないことを学んでいるものです。

Railsは、パラメータ(params)の改ざんを防ぐためのシンプルなパターンであるparams.permitを長年提供しています。これによって、悪意のあるユーザーによる、パラメータに属性を勝手に追加したりアプリの振る舞いを改変したりする(例: アプリの管理者にユーザー自身を割り当てる)といった目的のためにパラメータを改変しようとする操作から、アプリを保護します。

  def update
    user_params = params.require(:user).permit(:name, :favorite_pie)

    if @user.update(user_params)
      redirect_to :user
    else
      render :edit
    end
  end

この保護機能は、ユーザーがフォームを正しく送信した場合にも、ユーザーが無断で余分なフィールドをパラメータに挿入しようとした場合にも、適切に機能します。こうした攻撃はフィルタで除外されます。

しかし、ユーザーが正しいフォームを送信した場合や、余分なフィールドが追加された場合を保護するだけでは不十分です。RubyGems.orgで繰り返されているように、ユーザーが不正なパラメータを送信してアプリケーションの運用を妨害しようとすると、問題が発生し始めます。

Rails 8でこの問題を解決する方法が、この新しいparams.expectです。

🔗 params.expectの使い方

Railsのパラメータフィルタリング機能を詳しく知る余裕がないのであれば、Rails 8で以下のように書くだけで利用できます。

  def update
    # 古い書き方
    # user_params = params.require(:user).permit(:name, :favorite_pie)

    # 新しい書き方(期待されるパラメータがどのように反映されているかをご覧ください)
    user_params = params.expect(user: [:name, :favorite_pie])

    if @user.update(user_params)
    # ...
  end

新しいparams.expectを使って書くと、期待されるパラメータハッシュの構造に沿って処理されます。上の例では、:userというキーは省略不可であることが要求され、:name, :favorite_pieというキーを許可しています(:name:favorite_pieのどちらも配列やハッシュでない限り許可されます)。

params.expectメソッドは、ほぼすべてのコントローラのコードで使われているpermitrequireをほとんどの場合完全に置き換えられるはずです。パラメータがオプショナル(省略可能)でない限り、基本的にexpectを使うことをおすすめします。

本記事で詳しく説明しますが、このparams.expectが優れている点は、パラメータの構造も強制できることです。
params[:user]が返す値が期待通りでない場合(単独のStringArrayなど)、不正な形式のリクエストを受信したことがRailsで正しく認識され、"400 Bad request"エラーページを表示します。

さらに、配列を受け取るべきであることをexpectで指定する場合は、そのことを[[ :属性名 ]]構文で明示的に指定する必要があります。

# favorite_piesに配列を渡すことは許されるが、userに配列を渡すことは許されない
params.expect(user: [ :name, favorite_pies: [[ :flavor ]] ])
#     この記法で明示的に配列を指定していることに注目↑↑↑↑↑↑↑↑↑↑↑↑↑

本記事の残りの部分では、この新しい[[ :属性名 ]]構文が導入された理由としくみ、使い方、および、この変更で発生する可能性のある問題について詳しく説明します。

🔗 パラメータ改ざん時の問題

パラメータフィルタリングの古い方法における問題については冒頭で簡単に触れました。
では以下のリクエストをサーバーに送信したらどうなるでしょうか?

PATCH /user/1?user=hax

改修前の古いコードでは、以下のエラーが発生します。

NoMethodError in UsersController#update
undefined method `permit' for an instance of String

フィルタリングによって悪意のあるユーザー入力を阻止できたので、エラーになること自体はそれほど悪いことではありません。しかし500 Internal Server Errorになるのは良くありませんね。

このエラーは、おそらく例外トラッキングサービスにも通知されるでしょう。こんな警報でガンガン叩き起こされるのも良くありません。

NoMethodError: undefined method 'permit' for instance of String

"500 Internal Server Error"は単に迷惑なだけでなく、エラーメッセージが一般的すぎて内容がわかりません。NoMethodErrorを一律でrescueして、何もなかったかのように処理を続行するわけにもいきません。そんなことをしたら、本当に知らせて欲しかったエラーをうっかり抑制してしまうでしょう。

さて、RubyGems.orgで起きた問題はこうです。unhandledエラーである"500 Internal Server Error"を引き起こせることを発見したハッカーは、それを何度も繰り返しました。何度も何度も。

こうして"500 Internal Server Error"が大発生し、誰かさんを呼び出すアラートが鳴りっぱなしになるというわけです。😭

パラメータのフィルタリングをすべて慎重にチェックして、次のメソッドを呼び出す前にパラメータ内の個別の型をもれなくチェックするという方法もないわけではありませんが、これはいかにも面倒でみっともない方法です(信じていただきたいのですが、私は本当にこれをやろうとしました。それがきっかけで、この修正をRails本家にプッシュしたのです)。

🔗 ActionController::Parametersの内部を掘り下げる

この問題は、requireメソッドが、自分が返す型が何であるかについて関知しない(し、してはならない)ことが原因です。必要なときにActionController::Parametersのインスタンスがrequireから必ず返されることは保証しようがなく、ユーザーがその気になればStringArrayを返します。
残念ながら、requireがどんな型を返すかを制御しているのは、リクエストを送信するユーザーエージェントの方なのです。

(以下のコードでは、読みやすさのためURIの%エンコーディングを無視しています)

# ユーザーエージェントがハッシュを渡した場合:
PATCH /users/1/?user[name]=martin&user[favorite_pie]=pumpkin
# params = { "user" => { "name" => "martin", "favorit_pie" => "pumpkin" } }
# ユーザーエージェントが配列を渡した場合:
POST /pies?pies[][flavor]=pumpkin&pies[][flavor]=pecan
# params = { "pies" => [
#            { "flavor" => "pumpkin" },
#            { "flavor" => "pecan" }
#          ] }
# ユーザーエージェントが文字列を渡した場合:
POST /search?q=hello+world
# params = { "q" => "hello world" }

🔗 一体何が起きているのか?

問題のクエリをRailsコンソールで試してみましょう。

irb> params = ActionController::Parameters.new(user: "hax")
=> #<ActionController::Parameters {"user"=>"hax"} permitted: false>
irb> params.require(:user)
=> "hax"
irb> params.require(:user).permit(:name, :email)
undefined method `permit' for an instance of String (NoMethodError)

params.require(:user).permit(:name, :email)
                     ^^^^^^^

ご覧のように、require(:user)の戻り値は"hax"です。当然ですが、"hax"にはpermitという名前のメソッドはありません。

ユーザーには:userキーのハッシュを送信するよう丁寧にお願いしたつもりなのに、ユーザーは単なる文字列を送信してきました。うむむ。

🔗 ではどうすればよいか?

こうしたURIクエリ文字列のフォーマットを支配しているのはクライアントなので、期待されていないフォーマットをフィルタで除外するための方法が必要になります。

私たちの目標は、リクエストの形式が不正な場合に("500 Internal Server Error"ではなく)"400 Bad Request"をクライアントに返すことです。これは、ParameterMissingがraiseすればRailsが自動的に実行してくれるので、型が誤っている場合は以下のように自分でParameterMissingをraiseできます。

# 見苦しい方法
user_params = params.require(:user)
raise ActionController::ParameterMissing.new(:user, {}) unless user_params.respond_to?(:permit)
user_params = user_params.permit(:name, :favorite_pie)

RubyGems.orgでこの問題の解決に取り組んでいたときに、上よりもマシな方法を発見しました↓。次で述べる欠点は少々ありますが、十分動いてくれます。

# ちょっとだけマシな方法
user_params = params.permit(user: [:name, :favorite_pie]).require(:user)

Rails 8より前のバージョンで上の問題(や類似の問題)を解決するには、この方法が必要となります。

🔗 問題は「配列」

上の書き方は(permitの使い方があまり一般的ではないものの)改善されています。しかし、配列の扱いについては、最初に私たちが解決しようとしていた問題と同じくらい厄介な問題が残っていました。

permitメソッドの振る舞いは、実は一般に思われているよりもユルい面があります。permit(user: [:name, :favorite_pie])を指定した場合、以下の2つのパラメータがどちらも許可されてしまうのです。

# このparamsは許可される
ActionController::Parameters.new(user: { name: "Martin" })
# このparamsも許可される(期待通りでない可能性あり)
ActionController::Parameters.new(user: [ { name: "Martin" } ])

permitで値として配列を指定すると(例: [:name])、「それらのキーを持つハッシュ」も、「それらのキーを持つハッシュの配列」も許可されてしまうのです。これはいまいましいほどに不便なうえ、パラメータの指定方法によってはセキュリティ上の問題を引き起こす可能性もあります。

以下のような方法で解決してもいいのですが、ますますコードが見苦しくなります。

user_params = user_params.first if user_params.is_a?(Array)

paramsで配列を指定するための新構文

この配列の問題を回避するため、expect[:name]のような形式で指定するとハッシュだけを受け取るように改修されました。

逆に配列だけを指定したければ、以下の[[:text]]のような新しい形式を明示的に指定できます(この新しい形式が大きな混乱を呼ばないことを願っています)。

# 「二重配列」構文で明示的に配列のみを指定する
comments = params.expect(comments: [[:text]])

この明示的な配列構文で指定すると、配列だけを受け取るようになり、それ以外のパラメータ構造(ハッシュや文字列など)が渡されると"400 Bad Request"エラーが発生します。

params = ActionController::Parameters.new(
    comments: [
        {text: "hello"},
        {text: "world"}
    ]
)
comments = params.expect(comments: [[:text]])
<<<<<<< HEAD
=> { "comments" => [ {"text" => "hello"}, {"text" => "world"} ] }

=======
# => { "comments" => [ {"text" => "hello"}, {"text" => "world"} ] }
comments = params.expect(comments: [:text])
# => ActionController::ParameterMissing
>>>>>>> ceba427 (fixup last example)

パラメータ解析に残されていたこの曖昧さを解消し、すべてのRailsエンジニアが利用する機能の構文やセキュリティを改善したことで、誤ったアラームが削減され、アプリケーションデータの保護も改善されました。そして夜中にアラートで叩き起こされるエンジニアの数も減ることでしょう。

🔗 今後も改善を進めましょう

現在の私は、Ruby、Rubygems.org、そしてRuby on Railsのエコシステムをすべての人のために改善することに尽力しています。Rubygems.orgのチームと私は原則として、gemを(ローカルな改善ではなく)「アップストリーム」に反映する形で改善するようにしています。私たちは、アプリケーション構築するときにRubyがベストチョイスとなることを望んでいます。さらに私たちは、言語を超えたベストプラクティスにするため、PythonやRustなど他のエコシステムとも緊密に連携しています。

皆さんや皆さんの会社は、オープンソースに気持ちよく依存できていますか?最悪のシナリオを避けるには、オープンソースのメンテナンスに専念できるエンジニアが社内に少なくとも1人は必要です。Rubyをお使いの会社であれば、皆さんの業務はRubyGems.orgのチームに依存していることになります。私は皆さんと力を合わせて、皆さんのチームがRubyエコシステムで成功するためにお力添えしたいと思っています。

私や私の同僚と、皆さんの業務で頼りにされているコードの改善やメンテナンスをやってみたい方は、Cloud City Developmentまでお知らせください。ここでは世界レベルのチームを雇えます。

関連記事

保存版: Railsアプリケーションのセキュリティベストプラクティス(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。