Information disclosure via object inspect/output of internal state

HIGH
rails/rails
Commit: 4c0776608a2e
Affected: Rails 8.1.x on Ruby < 4.0 prior to this patch (up to 8.1.2, with 8.1.3 containing the fix)
2026-04-05 12:03 UTC

Description

The commit adds an InspectBackport module and uses instance_variables_to_inspect to limit what Object#inspect reveals on Ruby versions below 4.0. It replaces or augments previous custom inspect implementations that could leak internal state (e.g., @attribute, @type, @options) in various Rails components (ActiveModel, ActiveRecord, ActionText, etc.). This reduces information disclosure via inspect output and hardens public representations that could be exposed in logs, error messages, or debugging output. The changes include a new backport file (inspect_backport.rb) and conditional inclusion of the backport in classes for Ruby < 4, plus per-class instance_variables_to_inspect methods to control leaked state. Tests have been added to confirm that sensitive internals are not exposed via inspect.

Proof of Concept

PoC scenario illustrating prior information disclosure via inspect: Prerequisites: - Rails 8.1.x prior to this patch, running on Ruby < 4.0 - A class that previously exposed internal instance variables via inspect (for example ActiveModel::Error or similar classes with ivars such as @attribute, @type, and @options) 1) Demonstrate the vulnerability before the fix (before this patch is applied): # Assume ActiveModel::Error kept its original inspect implementation that includes ivars require 'active_model' class Person; end # Create an error that would populate internal ivars error = ActiveModel::Error.new(Person.new, :name, :too_short, count: 5) # This would leak internal state via inspect output puts error.inspect # Expected (vulnerable) output pattern (the exact memory address will vary): # <ActiveModel::Error:0x7f9b4a2c1b40 @attribute=:name, @type=:too_short, @options={:count=>5}> 2) After the patch (with Ruby < 4): # The patch includes InspectBackport and instance_variables_to_inspect control, so inspect will not leak internal ivars unless explicitly allowed. puts error.inspect # Expected safe output pattern (class and address only, no internal ivars). For example: # <ActiveModel::Error:0x7f9b4a2c1b40> Attack vector: - An attacker could rely on inspect output to infer private configuration or sensitive options from exceptions or logging paths, leaking internal state such as validation options, secret keys, or internal object representations if those ivars are exposed via inspect in logs or traces. Mitigation: Upgrade to Rails 8.1.3+ (or the patched version in this commit) which backports InspectBackport and restricts inspect output on Ruby < 4 by controlling instance_variables_to_inspect.

Commit Details

Author: Mark Bastawros

Date: 2026-02-11 22:22 UTC

Message:

Use instance_variables_to_inspect instead of custom inspect methods for ruby 4.0 Co-Authored-By: Jean Boussier <jean.boussier@gmail.com>

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: HIGH

Reasoning:

The commit replaces custom inspect implementations with an InspectBackport mechanism to prevent leaking internal state through inspect output on Ruby versions < 4. This reduces information disclosure risk (internal object state) and hardens the public-facing representations.

Verification Assessment

Vulnerability Type: Information disclosure via object inspect/output of internal state

Confidence: HIGH

Affected Versions: Rails 8.1.x on Ruby < 4.0 prior to this patch (up to 8.1.2, with 8.1.3 containing the fix)

Code Diff

diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index 5ac3bc646b18f..0ccece972d21a 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -3,6 +3,7 @@ # :markup: markdown require "action_dispatch" +require "active_support/inspect_backport" require "active_support/rescuable" module ActionCable @@ -165,11 +166,13 @@ def on_close(reason, code) # :nodoc: send_async :handle_close end - def inspect # :nodoc: - "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [].freeze + end + attr_reader :websocket attr_reader :message_buffer diff --git a/actioncable/test/connection/base_test.rb b/actioncable/test/connection/base_test.rb index af0e88aae1c19..a80bd498f1f69 100644 --- a/actioncable/test/connection/base_test.rb +++ b/actioncable/test/connection/base_test.rb @@ -133,6 +133,13 @@ def call(*) end end + test "inspect does not show internals" do + run_in_eventmachine do + connection = open_connection + assert_match(/\A#<ActionCable::Connection::BaseTest::Connection:0x[0-9a-f]+>\z/, connection.inspect) + end + end + private def open_connection env = Rack::MockRequest.env_for "/test", "HTTP_CONNECTION" => "upgrade", "HTTP_UPGRADE" => "websocket", diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb index 622cafa939bdf..001c40f5396aa 100644 --- a/actionpack/lib/abstract_controller/base.rb +++ b/actionpack/lib/abstract_controller/base.rb @@ -4,6 +4,7 @@ require "abstract_controller/error" require "active_support/descendants_tracker" +require "active_support/inspect_backport" require "active_support/core_ext/module/anonymous" require "active_support/core_ext/module/attr_internal" @@ -196,11 +197,13 @@ def config # :nodoc: @_config ||= self.class.config.inheritable_copy end - def inspect # :nodoc: - "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [].freeze + end + # Returns true if the name can be considered an action because it has a method # defined in the controller. # diff --git a/actiontext/lib/action_text/attachment.rb b/actiontext/lib/action_text/attachment.rb index 93081d8ce3827..c5fffd0b6fb8a 100644 --- a/actiontext/lib/action_text/attachment.rb +++ b/actiontext/lib/action_text/attachment.rb @@ -3,6 +3,7 @@ # :markup: markdown require "active_support/core_ext/object/try" +require "active_support/inspect_backport" module ActionText # # Action Text Attachment @@ -128,11 +129,13 @@ def to_s to_html end - def inspect - "#<#{self.class.name} attachable=#{attachable.inspect}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [:@attachable].freeze + end + def node_attributes @node_attributes ||= ATTRIBUTES.to_h { |name| [ name.underscore, node[name] ] }.compact end diff --git a/actiontext/test/unit/attachment_test.rb b/actiontext/test/unit/attachment_test.rb index 416994ed948c9..233104e893d07 100644 --- a/actiontext/test/unit/attachment_test.rb +++ b/actiontext/test/unit/attachment_test.rb @@ -95,6 +95,12 @@ class ActionText::AttachmentTest < ActiveSupport::TestCase assert_equal "pages/page", attachable.to_editor_content_attachment_partial_path end + test "inspect shows attachable" do + attachment = ActionText::Attachment.from_attachable(attachable) + + assert_match(/\A#<ActionText::Attachment:0x[0-9a-f]+ @attachable=/, attachment.inspect) + end + private def attachment_from_html(html) ActionText::Content.new(html).attachments.first diff --git a/activemodel/lib/active_model/error.rb b/activemodel/lib/active_model/error.rb index 4d693cc552b66..3043d62214530 100644 --- a/activemodel/lib/active_model/error.rb +++ b/activemodel/lib/active_model/error.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "active_support/inspect_backport" module ActiveModel # = Active \Model \Error @@ -195,9 +196,12 @@ def hash # :nodoc: attributes_for_hash.hash end - def inspect # :nodoc: - "#<#{self.class.name} attribute=#{@attribute}, type=#{@type}, options=#{@options.inspect}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" + + private + def instance_variables_to_inspect + [:@attribute, :@type, :@options].freeze + end protected def attributes_for_hash diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index e00cedfb91b15..928a6c7286b16 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -3,6 +3,7 @@ require "active_support/core_ext/array/conversions" require "active_support/core_ext/string/inflections" require "active_support/core_ext/object/deep_dup" +require "active_support/inspect_backport" require "active_model/error" require "active_model/nested_error" @@ -485,13 +486,13 @@ def generate_message(attribute, type = :invalid, options = {}) Error.generate_message(attribute, type, @base, options) end - def inspect # :nodoc: - inspection = @errors.inspect - - "#<#{self.class.name} #{inspection}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [:@errors].freeze + end + def normalize_arguments(attribute, type, **options) # Evaluate proc first if type.respond_to?(:call) diff --git a/activemodel/test/cases/error_test.rb b/activemodel/test/cases/error_test.rb index ef405630b7d30..ca31ece62edb8 100644 --- a/activemodel/test/cases/error_test.rb +++ b/activemodel/test/cases/error_test.rb @@ -252,4 +252,11 @@ def test_initialize assert_equal({ error: :invalid, foo: :bar }, error.details) end + + test "inspect" do + person = Person.new + error = ActiveModel::Error.new(person, :name, :too_short, count: 5) + + assert_match(/\A#<ActiveModel::Error:0x[0-9a-f]+ @attribute=:name, @type=:too_short, @options=#{Regexp.escape({ count: 5 }.inspect)}>\z/, error.inspect) + end end diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb index 4730248fa2fe8..a77a185913d4f 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -748,6 +748,6 @@ def call errors = ActiveModel::Errors.new(Person.new) errors.add(:base) - assert_equal(%(#<ActiveModel::Errors [#{errors.first.inspect}]>), errors.inspect) + assert_match(/\A#<ActiveModel::Errors:0x[0-9a-f]+ @errors=\[#<ActiveModel::Error/, errors.inspect) end end diff --git a/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb b/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb index 611fe5109b0b2..d2bd268565040 100644 --- a/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb +++ b/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "active_support/inspect_backport" require "openssl" module ActiveRecord @@ -79,11 +80,13 @@ def decrypt(encrypted_message) raise ActiveRecord::Encryption::Errors::Decryption end - def inspect # :nodoc: - "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [].freeze + end + def generate_iv(cipher, clear_text) if @deterministic generate_deterministic_iv(clear_text) diff --git a/activerecord/test/cases/encryption/cipher_test.rb b/activerecord/test/cases/encryption/cipher_test.rb index c4cdadbe8be7a..905af448e103f 100644 --- a/activerecord/test/cases/encryption/cipher_test.rb +++ b/activerecord/test/cases/encryption/cipher_test.rb @@ -62,4 +62,9 @@ class ActiveRecord::Encryption::CipherTest < ActiveRecord::EncryptionTestCase encrypted_text = @cipher.encrypt("Getting around with the ⚡️Go Menu", key: @key) assert_equal "Getting around with the ⚡️Go Menu", @cipher.decrypt(encrypted_text, key: @key) end + + test "inspect does not show secrets" do + aes_cipher = ActiveRecord::Encryption::Cipher::Aes256Gcm.new(@key) + assert_match(/\A#<ActiveRecord::Encryption::Cipher::Aes256Gcm:0x[0-9a-f]+>\z/, aes_cipher.inspect) + end end diff --git a/activesupport/lib/active_support/cache/file_store.rb b/activesupport/lib/active_support/cache/file_store.rb index 4c4e737e5069f..db5069bd8998a 100644 --- a/activesupport/lib/active_support/cache/file_store.rb +++ b/activesupport/lib/active_support/cache/file_store.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/core_ext/file/atomic" +require "active_support/inspect_backport" require "active_support/core_ext/string/conversions" require "uri/common" @@ -98,11 +99,13 @@ def delete_matched(matcher, options = nil) end end - def inspect # :nodoc: - "#<#{self.class.name} cache_path=#{@cache_path}, options=#{@options.inspect}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [:@cache_path, :@options].freeze + end + def read_entry(key, **options) if payload = read_serialized_entry(key, **options) entry = deserialize_entry(payload) diff --git a/activesupport/lib/active_support/cache/null_store.rb b/activesupport/lib/active_support/cache/null_store.rb index 7479a264adf20..eea90e4277514 100644 --- a/activesupport/lib/active_support/cache/null_store.rb +++ b/activesupport/lib/active_support/cache/null_store.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/inspect_backport" + module ActiveSupport module Cache # = Null \Cache \Store @@ -34,11 +36,13 @@ def decrement(name, amount = 1, **options) def delete_matched(matcher, options = nil) end - def inspect # :nodoc: - "#<#{self.class.name} options=#{@options.inspect}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" private + def instance_variables_to_inspect + [:@options].freeze + end + def read_entry(key, **s) deserialize_entry(read_serialized_entry(key)) end diff --git a/activesupport/lib/active_support/cache/redis_cache_store.rb b/activesupport/lib/active_support/cache/redis_cache_store.rb index fdfb2a68e337f..d033b8aaa155b 100644 --- a/activesupport/lib/active_support/cache/redis_cache_store.rb +++ b/activesupport/lib/active_support/cache/redis_cache_store.rb @@ -14,6 +14,7 @@ require "active_support/core_ext/hash/slice" require "active_support/core_ext/numeric/time" require "active_support/digest" +require "active_support/inspect_backport" module ActiveSupport module Cache @@ -170,9 +171,7 @@ def initialize(error_handler: DEFAULT_ERROR_HANDLER, **redis_options) super(universal_options) end - def inspect - "#<#{self.class} options=#{options.inspect} redis=#{redis.inspect}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" # Cache Store API implementation. # @@ -322,6 +321,10 @@ def stats end private + def instance_variables_to_inspect + [:@options, :@redis].freeze + end + def pipeline_entries(entries, &block) redis.then { |c| if c.is_a?(Redis::Distributed) diff --git a/activesupport/lib/active_support/inspect_backport.rb b/activesupport/lib/active_support/inspect_backport.rb new file mode 100644 index 0000000000000..811b5b56b8adf --- /dev/null +++ b/activesupport/lib/active_support/inspect_backport.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActiveSupport + # Provides a Ruby 4.0-compatible +inspect+ method for Ruby < 4.0. + # + # Ruby 4.0 introduced +instance_variables_to_inspect+, which lets classes + # control which instance variables appear in +inspect+ output without + # overriding +inspect+ entirely. This module backports that behavior so + # classes can define +instance_variables_to_inspect+ on any Ruby version. + # + # class MyClass + # include ActiveSupport::InspectBackport if RUBY_VERSION < "4" + # + # private + # def instance_variables_to_inspect + # [:@name, :@status].freeze + # end + # end + module InspectBackport # :nodoc: + def inspect + ivars = instance_variables_to_inspect + klass = self.class.name || self.class.inspect + addr = "0x%x" % object_id + + if ivars.empty? + "#<#{klass}:#{addr}>" + else + pairs = ivars.filter_map do |ivar| + if instance_variable_defined?(ivar) + "#{ivar}=#{instance_variable_get(ivar).inspect}" + end + end + "#<#{klass}:#{addr} #{pairs.join(", ")}>" + end + end + end +end diff --git a/activesupport/lib/active_support/key_generator.rb b/activesupport/lib/active_support/key_generator.rb index 8e75fba23b6b4..c0f1ca7bcf930 100644 --- a/activesupport/lib/active_support/key_generator.rb +++ b/activesupport/lib/active_support/key_generator.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "active_support/inspect_backport" require "concurrent/map" require "openssl" @@ -42,9 +43,12 @@ def generate_key(salt, key_size = 64) OpenSSL::PKCS5.pbkdf2_hmac(@secret, salt, @iterations, key_size, @hash_digest_class.new) end - def inspect # :nodoc: - "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>" - end + include ActiveSupport::InspectBackport if RUBY_VERSION < "4" + + private + def instance_variables_to_inspect + [].freeze + end end # = Caching Key Generator diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index af0b20e18d1b5..83b5517dca49b 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -3,6 +3,7 @@ require "openssl" require "base64" require "active_support/core_ext/module/attribute_accessors" +require "active_support/inspect_backport" require "active_support/messages/codec" require "active_support/messages/rotator" require "active_support/message_verifier" @@ -261,11 ... [truncated]
← Back to Alerts View on GitHub →