この記事は?
Rails で JSON のキーが重複する場合の振る舞いがバージョンごとに違うということが分かったので、2 つの記事を読みつつ調査しました。そして、キーが重複する場合にエラーを発生させる方法を調べました。
JSON のキー重複に関する振る舞いの変更
Rails 6.1 → 7.1
以下の記事を X で見つけて読みました。今回の調査のきっかけです。
この記事によると Rails 6.1 と 7.1 (7.1.2 以下) とで JSON のキーが重複する場合に以下のように振る舞いが変わっていたとのことです。
# シンボルと文字列でキーが重複しているが、結果はどうなる?
render(json: { id: 1, 'id' => 2 })
| Rails | Hash オブジェクトを JSON 文字列に変換する際の振る舞い |
|---|---|
| 6.1.x | シンボルのキーと文字列のキーが同一視され、キーは重複しない。最後の値を採用する。 |
| 7.1.2 | シンボルのキーと文字列のキーが別のキーとして扱われ、キーが重複する。 |
| 7.1.3 | (Rails 7.2 での修正がバックポートされ 6.1 と同じ振る舞いになった。) |
これにより API の振る舞いが変わり、ユーザ情報が漏洩するインシデントに繋がってしまったというのが記事の内容でした。恐ろしい 😱
なお、このキーの重複は以下の PR により Rails 7.2 で修正されています。この修正は Rails 7.1.3 にもバックポートされています。
Rails 7.2 → 8.0
先ほどの記事を読んで、このようなキー重複を防ぐ方法について調査したところ、以下の記事を見つけました。
この記事によると Rails 7.2 と 8.0 でまた JSON に関する振る舞いが変わってしまったらしいです。やれやれ 😇
まず前提として as_json は JSON のキーの重複を取り除きます。重複時は最後の値を採用します。これは Rails 7.2 でも 8.0 でも同様です。そしてコントローラが render(json: ...) を呼び出す際の挙動が以下のように変わっていたとのことです。
| Rails | コントローラが render(json: ...) を呼び出した際の振る舞い |
|---|---|
| 7.2.x | 内部的に as_json を呼び出す。キーが重複しない。 |
| 8.0.x |
内部的に as_json を呼び出さない。 シンボルのキーと文字列のキーが別のキーとして扱われ、キーが重複する。
|
Rails.version
#=> "7.2.2.2"
{ id: 1, 'id' => 2 }.to_json
#=> "{\"id\":1,\"id\":2}"
ActiveSupport::JSON.encode(id: 1, 'id' => 2)
#=> "{\"id\":1,\"id\":2}"
{ id: 1, 'id' => 2 }.as_json
#=> {"id"=>2}
ApplicationController.render(json: { id: 1, 'id' => 2 })
#=> "{\"id\":2}"
Rails.version
#=> "8.0.3"
# Rails 7.2 と挙動が同じ。
{ id: 1, 'id' => 2 }.to_json
#=> "{\"id\":1,\"id\":2}"
ActiveSupport::JSON.encode(id: 1, 'id' => 2)
#=> "{\"id\":1,\"id\":2}"
# Rails 7.2 と挙動が同じ。
{ id: 1, 'id' => 2 }.as_json
#=> {"id" => 2} # 細かいが "=>" の前後に半角スペースが入るようになっている。
# Rails 7.2.x と挙動が異なる!キー重複が取り除かれない!
ApplicationController.render(json: { id: 1, 'id' => 2 })
#=> "{\"id\":1,\"id\":2}" # Rails 7.2.x の場合は "{\"id\":2}"
つまり Rails 8 では render(json: ...) で JSON のキー重複が起きてしまうということですね 😱
Rails で JSON のキー重複を検知するには?
そもそも JSON のキー重複によって予期せぬ問題が起きないように、重複した場合は専用のエラーを発生させるようにしたいです。
幸い json Gem のバージョン 2.14.0 からキー重複を防ぐための allow_duplicate_key オプションが追加されました。さらに 3.0 からはキーが重複した際はデフォルトでエラーになる予定です。
| JSON | キー重複時の振る舞い |
|---|---|
| 2.14.0 |
allow_duplicate_key オプションを指定しない場合、Ruby の非推奨警告が発生する。allow_duplicate_key: false を明示的に指定すると、キー重複時にエラー (JSON::GeneratorError) が発生する (キー重複禁止) 。allow_duplicate_key: true を明示的に指定するとエラーも警告も発生しない (キー重複許可) 。 |
| 2.15.0 (2025/10/05 時点での最新) | (同上) |
| 3.0 (未リリース) |
allow_duplicate_key: true を明示的に指定しない限りエラーが発生するという挙動になる予定。 |
JSON::VERSION
#=> "2.14.0"
{ id: 1, 'id' => 2 }.to_json
#=> "{\"id\":1,\"id\":2}"
Warning[:deprecated] = true
# 非推奨警告が発生する。
{ id: 1, 'id' => 2 }.to_json
# .../ruby/3.4.0/gems/activesupport-8.0.3/lib/active_support/json/encoding.rb:110: warning: detected duplicate key "id" in {id: 1, "id" => 2}.
# This will raise an error in json 3.0 unless enabled via `allow_duplicate_key: true`
#=> "{\"id\":1,\"id\":2}"
# キーが重複しない。
{ id: 1, 'id' => 2 }.to_json(allow_duplicate_key: false)
#=> "{\"id\":2}"
では json 2.14.0 以上をインストールした上で、この警告を Rails の仕組みで検知できないでしょうか?上記で紹介した記事では
を設定する方法が紹介されていました (どちらも Rails 6.1 以降で設定可能)。対象の非推奨警告が発生した場合にエラーを発生させるという仕組みです。
# 他の非推奨警告もエラーにしてよいなら
# `disallowed_deprecation_warnings` に `:all` を指定することもできる。
config.active_support.disallowed_deprecation_warnings = [/detected duplicate key/]
# 非推奨警告をエラーにする。
config.active_support.disallowed_deprecation = :raise
しかし、この方法はうまく動作しませんでした。つまり JSON のキー重複時の非推奨警告を検知できませんでした 😭 どうやらこの方法は ActiveSupport::Deprecation の仕組みなので、今回のように Ruby の非推奨警告は検知できないようです。1
そのため、面倒ではありますが json Gem のバージョン 3.0 がリリースされるまでは自前で非推奨警告をエラーにする必要がありそうです。具体的には Ruby の Warning.warn メソッドを上書きするモンキーパッチを用意します 🐒
# `Warning.warn` メソッドを上書きする。
Warning.singleton_class.prepend(Module.new do
def warn(message, *args, **kwargs)
if message.include?('detected duplicate key') && message.include?('This will raise an error in json 3.0 unless enabled via `allow_duplicate_key: true`')
raise(JSON::GeneratorError.new(message))
end
super # 元の `Warning.warn` を呼び出す。
end
end)
そして Rails アプリケーションの起動時に Ruby の非推奨警告を出力するように config/boot.rb で Warning[:deprecated] = true を設定します。ただし、他の Ruby の非推奨警告も出力されるようになるので、既存の警告が多い場合はログの圧迫などに注意してください。
Warning[:deprecated] = true # 追記する。
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
require "bootsnap/setup"
この状態でキーが重複した JSON を生成しようとすると JSON::GeneratorError が発生します 🙆
Warning[:deprecated]
#=> true
# `JSON::GeneratorError` が発生する。
{ id: 1, 'id' => 2 }.to_json
# config/initializers/monkey_patches/warn.rb:5:in 'warn': .../ruby/3.4.0/gems/activesupport-8.0.3/lib/active_support/json/encoding.rb:110: warning: detected duplicate key "id" in {id: 1, "id" => 2}. (JSON::GeneratorError)
別案
JSON.parse メソッドを上書きして、必ず allow_duplicate_key: false を渡すようにするというモンキーパッチを用意する案も考えられます。
# `JSON.parse` メソッドを上書きする。
JSON.singleton_class.prepend(Module.new do
def parse(source, opts = nil)
opts = (opts || {}).dup
opts[:allow_duplicate_key] = false unless opts.key?(:allow_duplicate_key)
super(source, opts)
end
end
しかし、影響範囲を完全に調べることができなかったので、この記事では採用しませんでした。
まとめ
- Rails 6.1 から 7.1、そして 7.2 から 8.0 で JSON のキー重複に関する振る舞いが変わっている
- いずれにせよ JSON のキー重複を防ぎたい
- 現状の Rails の仕組みで JSON のキー重複をエラーとして検知するために
json2.14.0 以上をインストールし、かつWarning.warnをモンキーパッチで上書きする
バージョン情報
RUBY_VERSION
#=> "3.4.6"
# 本記事では Rails 7.2.2.2 も使用しました。
Rails.version
#=> "8.0.3"
# 2025/10/05 時点での最新は 2.15.0 です。
JSON::VERSION
#=> "2.14.0"
参考
- Ruby on Rails 6から7に上げただけで情報漏洩?Hash→JSON 変換の挙動変更で実際に生まれた脆弱性 - GMO Flatt Security Blog
- Rails 8 upgrade story: duplicate keys sneaking into our JSON responses | Arkency Blog
-
執筆後に気づきましたが、本文中の 2 つ目の記事にリンクがある同ブログの Do you tune out Ruby deprecation warnings? | Arkency Blog を読めば、Ruby の非推奨警告を
ActiveSupport::Deprecationの仕組みで検知できるようになるかもしれません (未検証) 。 ↩