Information disclosure via object inspect/output of internal state
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]