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

週刊Railsウォッチ: Active Storageのvariantsをeager loadingするメソッドが追加、Hotwire専用Discuss、AnyCable Proほか(20210621前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やTwitterでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙇

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 先週の改修(Rails公式ニュースより)

今回は公式更新情報とコミットリストのChangelogから見繕いました。Changelogに記載された変更が多めです。

「公式更新情報のうち最後の1つ以外は既にウォッチで取り上げていました」

🔗 Active RecordのBaseやCoreのクラス変数をActiveRecordクラスに移動して高速化


つっつきボイス:「cattr_accessormattr_accessorがなくなるのかと思ったら、ActiveRecord::BaseActiveRecord::Coreで使われているcattr_accessormattr_accessorによるクラス変数を親のActiveRecordクラスに移動するリファクタリングを行ったようですね」「mattrのmは何でしたっけ?」「モジュールです」

参考: Rails API cattr_accessor -- Module
参考: Rails API mattr_accessor -- Module


詳しくは#42442を参照。
クラス変数は端的に言って遅い。ancestor(先祖)が多いクラスでは特にそうで、ActiveRecord::Baseには60ものancestorがある。
これらのうちパフォーマンスに影響するものは一握りしかないが、一貫性を保つためにも、今後コントリビュータが新しくcattrmattrを追加せずに新しいパターンに沿って進めるためにも、クラス変数をすべて移行することを考えている。
ActiveRecord::Baseに残っていたのはこのプルリクで最後のはず。Active Recordの他のクラスにもあるが、それらのancestorチェインは長くないのでさほど影響しない。
ancestorチェインが長そうな他のクラスも見てみる(ActionController::Baseあたりか?)。
#42451より大意

🔗 Active Supportのvariantsでeager loadingをサポート


つっつきボイス:「variantは、100x100みたいな添付画像のサイズバリエーションでしたね」「これはわかりやすい改修: with_all_variant_recordsを使えばeager loadingできる」「いいねが21個もついてますね」「ものによってはパフォーマンスに結構影響しますし、これまで自力でN+1を回避していた人たちもいると思うので、これは欲しい機能ですね👍」

現在のActive Storageではvariantトラッキング(#37901)で添付ファイルごとにvariantが存在するかどうかをチェックするクエリが走る。通常のRailsのN+1防止策(includes)ではこれを防止できない。
このプルリクはwith_all_variant_recordsメソッドを追加し、かつincludesがActive Storageの添付ファイルで期待どおり動作するようになる。

user.vlogs.with_all_variant_records.map do |vlog|
  vlog.representation(resize: "100x100").processed
end

また、ビルトインのhas_manyスコープも更新されてvariantレコードも読み込めるようになった。したがって、現在N+1が発生している以下のようなコードはこのプルリクによってN+1が発生しなくなる。

User.where(id: user.id).with_attached_vlogs.map do |user|
  user.vlogs.map do |vlog|
    vlog.representation(resize: "100x100").processed
  end
end

#37901の多くのコメントが修正される。これを実装するにあたり、#39397のスタイルを大いに参考にした。
同PRより大意

🔗 replicaへの書き込み自動保護を無効に


つっつきボイス:「replicaなのでマルチデータベース関連の改修ですね」「今まではRails側でreplicaをデフォルトで書き込み禁止にしていたけど、replicaの書き込み禁止はデータベース側でやるべきなので削除した: これはそのとおりですね」「あ、そういうことですか」「想像ですけど、Rails側のロジックによる書き込み禁止は、gemと絡んだりすると迂回できてしまうことがあるんじゃないかな: replicaを使う場合はデータベース側でread専用のユーザーを作ることでリードオンリーにするのが普通ですし、書き込み禁止はデータベース側でやるべきだと思います」「なるほど」

# activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L139
      def preventing_writes?
-       return true if replica?
        return ActiveRecord::Base.connection_handler.prevent_writes if ActiveRecord.legacy_connection_handling
        return false if connection_klass.nil?

        connection_klass.current_preventing_writes
      end

この機能を使いたければ一応書き込み保護はできるが、今後は万全な保護としては扱われない。この書き込み保護はすべてのケースを正しく分類できるほど正確ではない。ユーザーは自分でreplicaを設定して書き込みを禁止し、許可されてないクエリの場合はデータベースのエラーに依存すべき。
別の解決方法: #42432
修正されるissue: #42432
同PRより大意

🔗 MySQLアダプタのクエリパラメータのセキュリティを向上

  • MySQLアダプタが、文字列の?で渡される数値やbooleanのパラメータを安全上の理由で文字列にキャストするようになった。

あるクエリ内で文字列と数値を比較する場合、MySQLは文字列を数値に変換する。つまり、たとえば"foo" = 0は暗黙で"foo"0にキャストしてTRUEと評価する。これはセキュリティ上の脆弱性につながる可能性がある。
Active Recordには、比較されるカラムの型を認識している場合の脆弱性に対する保護は既にあるが、?で渡す場合は引き続き脆弱だった。

User.where("login_token = ?", 0).first

上は以下を実行してしまう。

SELECT * FROM `users` WHERE `login_token` = 0 LIMIT 1;

修正後は以下を実行するようになる。

SELECT * FROM `users` WHERE `login_token` = '0' LIMIT 1;

Jean Boussier
同Changelogより大意


つっつきボイス:「MySQLアダプタでquote_bound_valueを追加して数値やブーリアン値を文字列にするようにしたんですね↓」「なるほど」「これまでもUser.where(login_token: 0).firstのようにハッシュの形で渡せば正しく文字列に変換されていたんですが、User.where("login_token = ?", 0).firstのように文字列とプレースホルダ?で値を渡すときはそうなっていなくてMySQLのキャストが効いていたらしい: 従来の挙動は普通にバグっぽいですね」

# activerecord/lib/active_record/connection_adapters/mysql/quoting.rb#L6
  module ConnectionAdapters
    module MySQL
      module Quoting # :nodoc:
+       def quote_bound_value(value)
+         case value
+         when Numeric
+           _quote(value.to_s)
+         when BigDecimal
+           _quote(value.to_s("F"))
+         when true
+           "'1'"
+         when false
+           "'0'"
+         else
+           _quote(value)
+         end
+       end
+

「ちなみにPostgreSQLだとこのようにカラムを文字列で指定するとデフォルトではクエリパーサーの段階でエラーになるんですよ(自動で型キャストする設定を付ければ変えられます)」「なるほど、ところで文中でbound variableとあるのはどういう意味なんでしょう?」「ここではプレースホルダ?で変数の値を渡すことを指していると思います」

🔗 Model.update!が追加


つっつきボイス:「Model.updatesave!は前からあったけど、エラーをraiseするModel.update!も追加されたんですね」

# activerecord/lib/active_record/persistence.rb#L336
+     def update!(id = :all, attributes)
+       if id.is_a?(Array)
+         if id.any?(ActiveRecord::Base)
+           raise ArgumentError,
+             "You are passing an array of ActiveRecord::Base instances to `update`. " \
+             "Please pass the ids of the objects by calling `pluck(:id)` or `map(&:id)`."
+         end
+         id.map { |one_id| find(one_id) }.each_with_index { |object, idx|
+           object.update!(attributes[idx])
+         }
+       elsif id == :all
+         all.each { |record| record.update!(attributes) }
+       else
+         if ActiveRecord::Base === id
+           raise ArgumentError,
+             "You are passing an instance of ActiveRecord::Base to `update`. " \
+             "Please pass the id of the object by calling `.id`."
+         end
+         object = find(id)
+         object.update!(attributes)
+         object
+       end
+     end
+

参考: Rails API update -- ActiveRecord::Persistence

🔗Rails

🔗 GitLab RunnerパッケージのGPGキーローテーション


つっつきボイス:「GitLabのセキュリティポリシーに基づいてGPGキーをローテーションしたそうです」「こういう暗号化キーのローテーションは定期的にも行われますね: GitLab Runnerのパッケージが対象なので、GitLab RunnerをパッケージアップデートしようとしたときにGPG検証エラーが出るようになると思います」「ふむふむ」

「GPG keyの有効期限が来ると新しいパッケージリストのアップデートが失敗するようになるので、新しいGPG keyで署名したパッケージをインストールする際には何らかの形で事前に新しいGPG keyをインストールしておく必要があります: そうしないとインストール時にGPG署名エラーでインストールできなくなってしまう」「なるほど」

GitLabでは、GitLab Runnerの公式パッケージへの署名にGPGキーを使っています。最近、この鍵や、GitLab Runnerの公式パッケージやバイナリを配布するための他のトークンが、GitLabのセキュリティポリシーに基づいて保護されていない事例があることが判明しました。
これまで弊社は、パッケージの不正な変更や、パッケージを保存しているサービスへのアクセスの証拠を発見していません。弊社チームの調査では、整合性ハッシュ、バケットのログとバージョン管理、パイプラインの履歴などを監査した結果、パッケージが不正に変更された可能性は極めて低いと結論づけました。
慎重を期して、リリースの署名や検証に用いられていたGPGキーは、不適切に保護されていた他のすべてのトークンとともにローテーションされました。
同記事冒頭より大意

🔗 AnyCableのPro版登場

anycable/anycable - GitHub


つっつきボイス:「AnyCableのPro版が出た🎉」「Evil Martiansの記事でもプロダクションレベルのRailsサンプルアプリとしてよく使われていますね↓」

HotwireはRailsを「ゼロJavaScript」でリアクティブにできるか?前編(翻訳)

「ところでAnyCableやSidekiqのようなソフトウェアの有償版ってどのぐらい使われているのかな? AnyCableはまずEvil Martians自身が使う製品という感じもするので、売れ行きはそれほど気にしないスタンスなのかもしれませんが」「AnyCableの場合はサポートを有料で買えるみたいですね」「少なくともSidekiqのエンタープライズ版を使ってる人は見たことないですね」

参考: Sidekiq -- Simple, efficient background jobs for Ruby.

以下はつっつき後に見つけたツイートです。

🔗 Hotwire専用Discuss

今レスが一番多いのは以下のスレッドでした。


つっつきボイス:「ruby-jp Slackで知りました」「discuss.hotwire.devという専用ドメインでDiscussアプリを立ち上げたのは、HotwireがRailsに限定しないフレームワークだからかもしれませんね」「そういえばDjangoの話題(PythonのWebフレームワーク)も出ていました」

参考: Django ドキュメント | Django ドキュメント | Django

HotwireはRailsを「ゼロJavaScript」でリアクティブにできるか?前編(翻訳)

🔗 Rack::SendfileミドルウェアにリクエストヘッダーからのRegexインジェクション問題

参考: rack/sendfile.rb at v2.2.2 · rack/rack
参考: Rails API send_file -- ActionController::DataStreaming


つっつきボイス:「記事にもある、Rack::SendFileミドルウェアをRailsのデフォルトから削除しようというissue↓は少し前につっつきでも話題にしましたね」「あのときは情報が少なかったので記事にしませんでしたが、その後で上の情報がhackerone.comで正式に公開されました」

参考: Remove Rack::SendFile from default middleware. · Issue #41148 · rails/rails

「こういうふうにX-Sendfile-typeX-Accel-Mappingで正規表現を注入してReDoS(正規表現DoS)したり、読み出してはいけないファイルを読み出したりできてしまうらしい↓」

# hackerone.comより
curl -i -H 'X-Sendfile-type:X-Accel-Redirect' -H 'X-Accel-Mapping:(([^\r])+.)+[^\r]([\r])+=/www/' http://localhost:3000/files
# hackerone.comより
curl -i -H 'X-Sendfile-type:X-Accel-Redirect' -H 'X-Accel-Mapping:/.*=/secret_internal' http://localhost:80/rails/files

「tenderloveさんのコメントには、Rack::SendFileは本来アプリケーションをプロキシの向こうに置いて使うべきもので、Railsのデフォルトミドルウェアから削除すべきだろうと書かれていました」「たしかRack::SendFileはもともと、ファイルのダウンロードをnginxなどに中継させるのに使ったりするミドルウェアだったと思います」

Sendfileミドルウェアは、bodyがファイルから提供されるレスポンスをインターセプトして、サーバー固有のX-Sendfileヘッダーで置き換えます。Webサーバはファイルの内容をクライアントに書き込む役割を果たします。これによりRubyのバックエンドで必要な負荷を大きく減らすことができ、最適化されたファイル配信コードをWebサーバで利用できるようになります。
このミドルウェアを利用するには、レスポンスのbodyがto_pathに応答し、リクエストにX-Sendfile-Typeヘッダが含まれている必要があります。Rack::Filesや他のコンポーネントはto_pathを実装しているので、アプリケーション内で何かをする必要はほとんどありません。通常、X-Sendfile-Typeヘッダの設定はウェブサーバで設定します。
Rack::Sendfileドキュメントより大意

Rack::SendFileのようにファイルダウンロードをミドルウェアでリダイレクトする方がたしかに効率はいいんですが、RailsとWebサーバーの間でロジックをやりとりするとこのような問題が生じる可能性もありそうですね」「なるほど」


ひとまず自分のRailsアプリからはRack::SendFileを削除しました。

$ .bin/rails middleware
use Rack::MiniProfiler
use Sqreen::ShrinkWrap
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Sqreen::Middleware
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use ActionDispatch::DebugExceptions
use BetterErrors::Middleware
use Sqreen::ErrorHandlingMiddleware
use Sqreen::RailsMiddleware
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CacheStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
use Rack::Attack
use Rack::Attack
use Bullet::Rack
run Enno::Application.routes

🔗 その他Rails


つっつきボイス:「Parameters.newにcontextを渡せるようにして、パラメータがunpermittedの場合にどのコントローラのどのアクションでunpermittedなパラメータが渡されたかをInstrumentationのログから取れるようになったんですね↓👍」「今まではunpermittedだったときのキーしか取れなかったそうです」

# 同記事より
context = { controller: self.class.name, action: action_name }
request_params = { user: { name: "Francesco", email: "fransceso@example.com", role: "admin" } }

params = ActionController::Parameters.new(request_params, context)
params.permit(user: [:name, :email])

# Unpermitted parameter: :role. Context: { controller: UsersController, action: create }

参考: Active Support の Instrumentation 機能 - Railsガイド


前編は以上です。

バックナンバー(2021年度第2四半期)

週刊Railsウォッチ(20210615後編)RubyのRBSを理解する、シンボルがGCされないとき、Terraform 1.0リリースほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

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