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

週刊Railsウォッチ: sanitize_sql_likeは重要、X-XSS-Protectionヘッダーのデフォルト変更、kredis gemほか(20211206前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

今回は以下の2つの公式更新情報から見繕いました。

🔗 SQLログで属性をフィルタする機能が追加

SQLログで属性をフィルタする

従来のSQLクエリのログではActiveRecord::Base.filter_attributesがフィルタされていなかった。
今回、prepared_statementが有効になっていれば属性が[FILTERED]でフィルタされるようになった。

# 改修前
  Foo Load (0.2ms)  SELECT "foos".* FROM "foos" WHERE "foos"."passw" = ? LIMIT ?  [["passw", "hello"], ["LIMIT", 1]]
# 改修後
  Foo Load (0.5ms)  SELECT "foos".* FROM "foos" WHERE "foos"."passw" = ? LIMIT ?  [["passw", "[FILTERED]"], ["LIMIT", 1]]

Aishwarya Subramanian
同Changelogより


つっつきボイス:「お、filter_attributesでフィルタをかけたときにSQLログでも属性がフィルタされるようになったのね」「これは賢い」「SQLログはログレベルを:debugにしないと出ませんが、これはやっておくべき👍」

参考: 3.1 Rails全般の設定 -- Rails アプリケーションを設定する - Railsガイド

🔗 Active Storageの改修2点


つっつきボイス:「1個目の#41544は、GCS(Google Cloud Storage)のcompose機能に対応したのか」「GCSのcomposeはファイルを結合する機能なのね↓」「GCSのcompose機能を使うと、たとえばログファイルを手元に持ってきて結合したりせずにGCS上でAPIを使って結合したりできるのか、へ〜!」「そんな機能があったんですね」

参考: Cloud Storage  |  Google Cloud
参考: GCS上のファイルを結合する - Qiita

「ファイル変更を見るとAWS S3やMicrosoft Azureにも追加されてますね」「どれどれ、S3やAzureの場合はeachで回して結合している↓」「S3だとIO.copy_streamで持ってきたものをeachでそのまま結合しているのね」「Azureも似たような感じ」

# 
    def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
      content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename

      object_for(destination_key).upload_stream(
        content_type: content_type,
        content_disposition: content_disposition,
        part_size: MINIMUM_UPLOAD_PART_SIZE,
        metadata: custom_metadata,
        **upload_options
      ) do |out|
        source_keys.each do |source_key|
          stream(source_key) do |chunk|
            IO.copy_stream(StringIO.new(chunk), out)
          end
        end
      end
    end
# activestorage/lib/active_storage/service/azure_storage_service.rb#110
    def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
      content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename

      client.create_append_blob(
        container,
        destination_key,
        content_type: content_type,
        content_disposition: content_disposition,
        metadata: custom_metadata,
      ).tap do |blob|
        source_keys.each do |source_key|
          stream(source_key) do |chunk|
            client.append_blob_block(container, blob.name, chunk)
          end
        end
      end
    end

「GCSの場合は専用のbucket.composeを使ってGCS上で結合できるからシンプル↓」「GCSはローカルに持ってこなくていいので効率よさそう👍」

# activestorage/lib/active_storage/service/gcs_service.rb#137
    def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
      bucket.compose(source_keys, destination_key).update do |file|
        file.content_type = content_type
        file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
        file.metadata = custom_metadata
      end
    end

「2つ目の#43294は、Active Storageが作るメタデータ以外に"custom"のような任意構造のメタデータも追加できるようになった↓」「これは使いたい人いそう」「今までなかったんですね」

# activestorage/test/models/blob_test.rb#L269
+ test "updating the metadata updates service metadata" do
+   blob = directly_upload_file_blob(filename: "racecar.jpg", content_type: "application/octet-stream")
+
+   expected_arguments = [
+     blob.key,
+     {
+       content_type: "application/octet-stream",
+       disposition: :attachment,
+       filename: blob.filename,
+       custom_metadatata: { "test" => true }
+     }
+   ]
+
+   assert_called_with(blob.service, :update_metadata, expected_arguments) do
+     blob.update!(metadata: { custom: { "test" => true } })
+   end
+ end

🔗 upsert_allupdate_only:オプションが追加

upsert_allメソッドに:update_onlyオプションが追加され、コンフリクト時に更新するカラムのリストを指定できるようになった。
従来は、:on_duplicate:オプションで更新用のSQL文をカスタマイズするしかなかった。今回:update_only:オプションが追加されたことで、コンフリクト時に更新したいカラムのリストを渡せるようになった。

Commodity.upsert_all(
  [
    { id: 2, name: "Copper", price: 4.84 },
    { id: 4, name: "Gold", price: 1380.87 },
    { id: 6, name: "Aluminium", price: 0.35 }
  ],
  update_only: [:price] # Only prices will be updated
)

同Changelogより


つっつきボイス:「upsert_allupdate_only:オプションが追加されて、更新時に更新するカラムを指定できるようになった🎉」「上で言うと、レコードが既に存在する場合はpriceだけが更新されるということですね」

「テストコードでは、upsert_allを2回実行して、2回目だけupdate_only: :nameを指定すると、nameだけ更新されてisbnは更新されない↓」「レコードが存在しなかった場合は今までどおり全部のカラムがINSERTされるんですね、なるほど理解です」「テストコードを見れば振る舞いがわかるようになっていてありがたい: ドキュメントだけだと振る舞いがわかりにくいときはテストコードも見るのがおすすめです」

# activerecord/test/cases/insert_all_test.rb#L345
+ def test_upsert_all_only_updates_the_column_provided_via_update_only
+   Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }]
+   Book.upsert_all [{ id: 101, name: "Perelandra 2", author_id: 7, isbn: "111111" }], update_only: :name
+
+   book = Book.find(101)
+   assert_equal "Perelandra 2", book.name, "Should have updated the name"
+   assert_equal "1974522598", book.isbn, "Should not have updated the isbn"
+ end

🔗 Pathname#existenceが追加


つっつきボイス:「Active Supportにexistenceメソッドが入った↓」

# activesupport/lib/active_support/core_ext/pathname/existence.rb#18
  def existence
    self if exist?
  end

「なるほど、Active SupportのObject#presenceみたいなチェックをファイルの存在について行ってチェインできるのか↓」「ファイルが存在すればそのファイル自身を返して、存在しなければnilを返すんでしょうね」「後置ifだとチェックと処理が分かれてしまうけど、existence&.readなら簡潔に書き下せるのがいいですね👍」

# 同PRより
content = Rails.root.join('file').read if Rails.root.join('file').exist?
↓
content = Rails.root.join('file').existence&.read

Object#presenceみたいな便利メソッドは昔から好きなので、いつかRuby本家に入ってくれたら嬉しいけど、Railsのpresent?blank?あたりの仕様がRuby側で議論になりそうな気もするので難しいかも」「議論になりそうなのわかります」「Pathname#existenceならRubyに入っても大丈夫そう」

Railsでnil? blank? empty? present?を使いこなそう

🔗 Rails.error.handleにフォールバック値を指定できるようになった

handleにオプションでfallback:パラメータを渡せるようにし、raiseがハンドリングされたときにこの値が返されるようにした。これにはnull objectやデフォルト値、またはnil返しと異なる何らかのフラグが使える。

user = Rails.error.handle(fallback: User.anonymous) do
  User.find_by(params)
end

同PRより抜粋


つっつきボイス:「これは前回入ったRails.errorへの機能追加ですね(ウォッチ20211129)」「フォールバック値を指定する機能はたしかに欲しい」

🔗 コネクションプールでroleshardも指定可能に


つっつきボイス:「マルチプルデータベース関連のプルリクが多い@eileencodesさんによるものですね」「pool_configresolve_pool_configroleshardも指定できるようになった: シャーディングの条件によってコネクションプールを分けるなど、コネクションプールでこれらを細かく指定できると便利なときがありそうかなと思いました」

# activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb#L129
-       pool_config = resolve_pool_config(config, owner_name)
+       pool_config = resolve_pool_config(config, owner_name, role, shard)

🔗 X-XSS-Protectionヘッダーのデフォルト値が0に変更

このX-XSS-Protectionヘッダーは既に非推奨化されており、このヘッダーを当初から実装していた主要なモダンブラウザ(ただしFirefoxでは実装されなかった)では、これをトリガーしていたXSS Auditorが既に削除されてContent Security Policy(CSP)に置き換わっている。

OWASPでは、セキュリティ問題を増やす可能性のあるこのヘッダーを0に設定して古いブラウザのデフォルトの振る舞いを無効にするよう指示している。
この新しい振る舞いはRails 7.0からフレームワークのデフォルトとして追加された。
このヘッダーを0に設定する理由については以下を参照:


つっつきボイス:「OWASPが正式にX-XSS-Protectionヘッダーを0にすべきと言っているならそのとおりにするのがよさそう👍」「X-XSS-Protectionヘッダー自体を削除しないのはなぜなんだろう?🤔」「このヘッダーに対応しているブラウザがまだあるからじゃないかな: 一度広まったものをなくすのは大変」「ほんとにそうですよね」

参考: 9 デフォルトのヘッダー -- Rails セキュリティガイド - Railsガイド

後で調べると、現時点のsecure_headers gemはデフォルトのX-XSS-Protectionヘッダーがまだ0になっていないそうです↓。

参考: Should x-xss-protection default to “0” instead of “1; mode=block” · Issue #439 · github/secure_headers

🔗 PerThreadRegistryを削除して非推奨化


つっつきボイス:「Rails内部用のPerThreadRegistryはずっと前に非推奨化されていたけどwarningが出されていなかったので、あらためてwarningを出すようにしたそうです」「内部ということはRailsアプリ開発者が意識しなくていいヤツですね、よかった〜」

🔗Rails

🔗 Railsのsanitize_sql_likeは重要


つっつきボイス:「ちょうど今日のBPS社内勉強会でセキュリティを取り上げたときに話題になったRailsのsanitize_sql_likeメソッドを取り上げてみました」「そうそう、これはRails開発者がぜひとも知っておくべきサニタイズ用メソッドですね」

参考: RailsにてSQLでのワイルドカード文字をエスケープしてくれるsanitize_sql_likeは何をしているのか - Qiita

「これって何ですか?」「SQL文のLIKEの文字列でだけ機能する特殊文字は、通常のsanitize_sqlだけではエスケープされないんですよ」「え〜!そうだったんですか?」

「LIKEでは%がワイルドカード文字というのはよく知られていますけど、実はアンダースコア_も任意の1文字というワイルドカードなんですよ」「ありゃ〜、これは知っておかないとマズいヤツだ」「LIKEで使う文字列にsanitize_sql_likeをかけないとバグになりますし、下手をすると情報漏えいにもつながるので要注意」

# 安全な書き方(to_sしないと配列などをparamsに渡せてしまう)
hoge.where('name = ?', params[:name].to_s)

# =ではなくLIKEだと同じ書き方が危険になる(name値に%や_があるとワイルドカードと解釈される可能性)
hoge.where('name LIKE ?', params[:name].to_s + "%")

# LIKEの場合はsanitize_sql_likeを明示的にかける必要がある
hoge.where('name LIKE ?', ActiveRecord::Base.sanitize_sql_like(params[:name].to_s) + "%")

なお実際に使うときは+ "%"よりも以下のように式展開"#{}"にするのが一般的です。

hoge.where('name LIKE ?', "%#{ActiveRecord::Base.sanitize_sql_like(params[:name].to_s)}%")

訂正(2022/10/24)

jnchitoさんからご指摘をいただき、上のコードを修正・追記しました。ありがとうございます!🙇

「意外ですが、edgeガイドにもまだsanitize_sql_likeは載っていませんでした↓」「ちなみに自分はきっとそういうメソッドがあるはずだと思って探して見つけました」「たしかに、ないとおかしい機能ですよね」「今のコードでLIKEを使っているところを全文検索して探して修正してもいいぐらい大事」「この後やらなきゃ」

参考: Securing Rails Applications — Ruby on Rails Guides

🔗 AWS Lambda FunctionsをRubyで書く(Ruby Weeklyより)


つっつきボイス:「AWS Lambda FunctionsをRubyで書く手順を解説した記事、よさそう👍」

参考: AWS Lambda(イベント発生時にコードを実行)| AWS

「Lambda Functionsのコードを書くのは別に大変ではありませんが、そのコードをどう管理してデプロイするかの方に力を使いますよね」「そうそう」「管理やデプロイの方法はたくさんあるので、要件や用途に合わせて選ぶ方が大変: Lambda Functionsは単体で使うよりも他のコードと連携させるものなので、そうなるとIAMポリシーなどを適切に設定しないと動かないし、IAMポリシーも管理しようとするとTerraformを使うことになったりするのが悩ましい」

「サーバーレスフレームワークとTerraformをどう住み分けさせるかとかも考えないといけないですよね」「そうそう、サーバーレスフレームワーク単体でもできるけど、その場合は既存のTerraformとの整合性を壊さないようにしないといけないとかで複雑になりがち」

参考: Terraform by HashiCorp

「AWS Cloud Development Kit(CDK)↓も使ってみたいんですが、Terraformと別管理になるとしたら複雑になるんじゃないかという点が気になって、まだ手出ししてません」「以前そのあたりを調べたことがあったんですが、割と強い権限が必要になる機能がいくつもあって、そのときは諦めました」「IAMのロールやポリシーを作成できる権限はどうしても強くなるので、それならTerraformでやる方がいいのかなという気持ちにもなります」「悩みは尽きない...」

参考: AWS クラウド開発キット – アマゾン ウェブ サービス

🔗 kredis: RedisをRuby風にアクセス可能にするラッパー(Ruby Weeklyより)

rails/kredis - GitHub


つっつきボイス:「Keyed Redisだからkredisなのか」「よく見たらGitHubのRailsリポジトリに置かれてるからRailsの機能なのかな?」「新しい割に★が多いですね」「DHHもコントリビュータに入っています」

redis/redis - GitHub

Kredis.string "mystring"のように型名とキーを指定すると.valueで値を取り出せる、まさにキーバリューストアで型やJSONのようなデータ構造が使えるもののようですね↓」「GETSETはRedisの操作なのか」

# 同リポジトリより
string = Kredis.string "mystring"
string.value = "hello world!"  # => SET mystring "hello world"
"hello world!" == string.value # => GET mystring

integer = Kredis.integer "myinteger"
integer.value = 5  # => SET myinteger "5"
5 == integer.value # => GET myinteger

decimal = Kredis.decimal "mydecimal" # accuracy!
decimal.value = "%.47f" % (1.0/10) # => SET mydecimal "0.10000000000000000555111512312578270211815834045"
BigDecimal("0.10000000000000000555111512312578270211815834045e0") == decimal.value # => GET mydecimal

float = Kredis.float "myfloat" # speed!
float.value = 1.0/10 # => SET myfloat "0.1"
0.1 == float.value # => GET myfloat

boolean = Kredis.boolean "myboolean"
boolean.value = true # => SET myboolean "t"
true == boolean.value # => GET myboolean

datetime = Kredis.datetime "mydatetime"
memoized_midnight = Time.zone.now.midnight
datetime.value = memoized_midnight # SET mydatetime "2021-07-27T00:00:00.000000000Z"
memoized_midnight == datetime.value # => GET mydatetime

json = Kredis.json "myjson"
json.value = { "one" => 1, "two" => "2" }  # => SET myjson "{\"one\":1,\"two\":\"2\"}"
{ "one" => 1, "two" => "2" } == json.value  # => GET myjson

「見た感じではRedisの機能にひととおり対応していそう」「Kredis.listという書き方もできるのね↓」

# 同リポジトリより
list = Kredis.list "mylist"
list << "hello world!"               # => RPUSH mylist "hello world!"
[ "hello world!" ] == list.elements  # => LRANGE mylist 0, -1

integer_list = Kredis.list "myintegerlist", typed: :integer
integer_list.append([ 1, 2, 3 ])        # => RPUSH myintegerlist "1" "2" "3"
integer_list << 4                       # => RPUSH myintegerlist "4"
[ 1, 2, 3, 4 ] == integer_list.elements # => LRANGE myintegerlist 0 -1

「Active Recordモデルにもこうやって書けるのね↓」「RPUSH people:5:names "David" "Heinemeier" "Hansson"みたいにActiveRecordのモデルに対してattributes的にRedisに格納する値を設定できる、へ〜!」「面白そう!」

# 同リポジトリ
class Person < ApplicationRecord
  kredis_list :names
  kredis_list :names_with_custom_key, key: ->(p) { "person:#{p.id}:names_customized" }
  kredis_unique_list :skills, limit: 2
  kredis_enum :morning, values: %w[ bright blue black ], default: "bright"
end

person = Person.find(5)
person.names.append "David", "Heinemeier", "Hansson" # => RPUSH people:5:names "David" "Heinemeier" "Hansson"
true == person.morning.bright?                       # => GET people:5:morning
person.morning.value = "blue"                        # => SET people:5:morning
true == person.morning.blue?                         # => GET people:5:morning

「RedisのプリミティブなAPIをRubyのシンプルなオブジェクトでラップしたRubyインターフェイスライブラリという感じかな」「RDBがあるのにkredisを使う理由って何だろう?🤔」「速度的な理由でRDBの代わりにRedisにデータを入れておきたいことはあるかも」「gem 'kredis'をGemfileに追加してインストールとあるので、デフォルトでRailsに入るわけではなさそう」


前編は以上です。

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

週刊Railsウォッチ: フォームヘルパーの改修、Railsの監査ログgem比較、DHHとimport-mapほか(20211129前編)

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

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

Rails公式ニュース

Ruby Weekly


CONTACT

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