Destructive actions protection (production environment) misconfiguration / bypass risk

HIGH
rails/rails
Commit: d1981d037f03
Affected: 8.1.0 - 8.1.3
2026-04-05 13:05 UTC

Description

The commit moves the protected_environments configuration from a per-class scope (via class_attribute) to a global setting on the ActiveRecord module (ActiveRecord.protected_environments) and updates all checks to reference this global setting. Previously, database task checks referenced ActiveRecord::Base.protected_environments, which could be misaligned with subclass-specific configurations and lead to inconsistent protection against destructive actions in production. The fix ensures a single, global source of truth for production-destructive-action protection, deprecates the per-class accessors, and updates related code paths (migration checks, model schema defaults, and database task checks) to consult ActiveRecord.protected_environments. This reduces the risk that destructive actions could bypass protection due to scoping issues or per-class configuration. A deprecation path is provided for existing ActiveRecord::Base.protected_environments usage. Impact mirrors a configuration vulnerability rather than an remote-exploit, centered on ensuring destructive-action protection in production is consistently enforced across database tasks.

Proof of Concept

PoC: Demonstrates potential bypass of production destruction protection due to global vs. per-class scoping 1) In a Rails app prior to the fix, suppose an initializer (or an actor with config access) sets the global protection to an empty array, effectively disabling protection: # config/initializers/bypass_protection.rb ActiveRecord.protected_environments = [] 2) Attempt a destructive database task in production, which should be blocked by protection if the guard is functioning correctly: RAILS_ENV=production bundle exec rake db:drop If the global protection array is empty, the protection check ActiveRecord.protected_environments.include?(last_stored_environment) will not raise, and the destructive operation proceeds, illustrating how misconfiguration can bypass production safeguards. Note: This PoC relies on the ability to modify ActiveRecord.protected_environments. The fix introduces ActiveRecord.protected_environments as the single source of truth and deprecates ActiveRecord::Base.protected_environments to minimize per-class bypasses. In a properly configured environment after the fix, the global protection should be enforced consistently, and deprecation warnings guide migration away from per-class usage.

Commit Details

Author: Hartley McGuire

Date: 2026-02-07 08:11 UTC

Message:

Move protected_environments to ActiveRecord module It was originally [implemented][1] as a `class_attribute`, and later [changed][2] to not use `class_attribute (but still behave like `class_attribute`). However, its usage inside database tasks has always checked `ActiveRecord::Base`, meaning it doesn't benefit at all from the inheritance of `class_attribute` or the ability to configure subclasses individually. Since the configuration is effectively global, this commit moves the configuration to the top level `ActiveRecord` module. The old "`class_attribute`" methods are deprecated, but continue to work as they did before. [1]: 900bfd94a9c3c45484d88aa69071b7a52c5b04b4 [2]: 1a411d4b7f34df2471a93517ad15cc2682bbdbe2

Triage Assessment

Vulnerability Type: Destructive actions protection (production environment)

Confidence: HIGH

Reasoning:

The commit moves the protected_environments configuration from per-class scope to a global ActiveRecord module, ensuring that the protection against destructive actions in protected environments (e.g., production) is enforced consistently across database tasks. This addresses a security-related configuration issue where environment protection could be inconsistently applied, and updates checks to reference ActiveRecord.protected_environments.

Verification Assessment

Vulnerability Type: Destructive actions protection (production environment) misconfiguration / bypass risk

Confidence: HIGH

Affected Versions: 8.1.0 - 8.1.3

Code Diff

diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 4d118254e2117..bc8f7d064cbc5 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -191,6 +191,19 @@ module Tasks singleton_class.attr_accessor :lazily_load_schema_cache self.lazily_load_schema_cache = false + ## + # :singlton-method: protected_environments + # The array of names of environments where destructive actions should be + # prohibited. By default, the value is <tt>["production"]</tt>. + singleton_class.attr_reader :protected_environments + + # Sets an array of names of environments where destructive actions should be + # prohibited. + def self.protected_environments=(environments) + @protected_environments = environments.map(&:to_s) + end + self.protected_environments = ["production"] + ## # :singleton-method: schema_cache_ignored_tables # A list of tables or regex's to match tables to ignore when diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 2affd00d609ac..fc0214df77127 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1369,7 +1369,7 @@ def current_environment # :nodoc: end def protected_environment? # :nodoc: - ActiveRecord::Base.protected_environments.include?(last_stored_environment) if last_stored_environment + ActiveRecord.protected_environments.include?(last_stored_environment) if last_stored_environment end def last_stored_environment # :nodoc: diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 41ec5b66306de..41d29f01a10a2 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -178,8 +178,6 @@ module ModelSchema alias_method :inheritance_column=, :real_inheritance_column= end - self.protected_environments = ["production"] - self.ignored_columns = [].freeze self.only_columns = [].freeze @@ -313,7 +311,14 @@ def full_table_name_suffix # :nodoc: # The array of names of environments where destructive actions should be prohibited. By default, # the value is <tt>["production"]</tt>. def protected_environments - if defined?(@protected_environments) + ActiveRecord.deprecator.warn <<~MSG + ActiveRecord::Base.protected_environments is deprecated in favor of + ActiveRecord.protected_environments and will be removed in Rails 9.0. + MSG + + if self == ActiveRecord::Base + ActiveRecord.protected_environments + elsif defined?(@protected_environments) @protected_environments else superclass.protected_environments @@ -322,7 +327,16 @@ def protected_environments # Sets an array of names of environments where destructive actions should be prohibited. def protected_environments=(environments) - @protected_environments = environments.map(&:to_s) + ActiveRecord.deprecator.warn <<~MSG + ActiveRecord::Base.protected_environments= is deprecated in favor of + ActiveRecord.protected_environments= and will be removed in Rails 9.0. + MSG + + if self == ActiveRecord::Base + ActiveRecord.protected_environments = environments + else + @protected_environments = environments.map(&:to_s) + end end def real_inheritance_column=(value) # :nodoc: diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index e003c1950b730..33f2b0f04e665 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -1923,15 +1923,53 @@ def test_default_values_are_deeply_dupped end test "protected environments by default is an array with production" do - assert_equal ["production"], ActiveRecord::Base.protected_environments + assert_equal ["production"], ActiveRecord.protected_environments + + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["production"], ActiveRecord::Base.protected_environments + end end def test_protected_environments_are_stored_as_an_array_of_string - previous_protected_environments = ActiveRecord::Base.protected_environments - ActiveRecord::Base.protected_environments = [:staging, "production"] - assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments + previous_protected_environments = ActiveRecord.protected_environments + + ActiveRecord.protected_environments = [:staging, "production"] + + assert_equal ["staging", "production"], ActiveRecord.protected_environments + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments + end + + assert_deprecated(ActiveRecord.deprecator) do + ActiveRecord::Base.protected_environments = [:prod, :staging] + end + + assert_equal ["prod", "staging"], ActiveRecord.protected_environments + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["prod", "staging"], ActiveRecord::Base.protected_environments + end ensure - ActiveRecord::Base.protected_environments = previous_protected_environments + ActiveRecord.protected_environments = previous_protected_environments + end + + def test_deprecated_protected_environments_on_subclasses + model = Class.new(ActiveRecord::Base) + + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["production"], model.protected_environments + end + + assert_deprecated(ActiveRecord.deprecator) do + model.protected_environments = [:prod] + end + + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["prod"], model.protected_environments + end + + assert_deprecated(ActiveRecord.deprecator) do + assert_equal ["production"], ActiveRecord::Base.protected_environments + end end test "#present? and #blank? on ActiveRecord::Base classes" do diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 12c095037fc97..e0aa2ae1849fc 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -95,7 +95,7 @@ def teardown end def test_raises_an_error_when_called_with_protected_environment - protected_environments = ActiveRecord::Base.protected_environments + protected_environments = ActiveRecord.protected_environments current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment ActiveRecord::Base.connection_pool.internal_metadata[:environment] = current_env @@ -110,18 +110,18 @@ def test_raises_an_error_when_called_with_protected_environment # Assert no error ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit") - ActiveRecord::Base.protected_environments = [current_env] + ActiveRecord.protected_environments = [current_env] assert_raise(ActiveRecord::ProtectedEnvironmentError) do ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit") end end ensure - ActiveRecord::Base.protected_environments = protected_environments + ActiveRecord.protected_environments = protected_environments end def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol - protected_environments = ActiveRecord::Base.protected_environments + protected_environments = ActiveRecord.protected_environments current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment ActiveRecord::Base.connection_pool.internal_metadata[:environment] = current_env @@ -136,13 +136,13 @@ def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_ # Assert no error ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit") - ActiveRecord::Base.protected_environments = [current_env.to_sym] + ActiveRecord.protected_environments = [current_env.to_sym] assert_raise(ActiveRecord::ProtectedEnvironmentError) do ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit") end end ensure - ActiveRecord::Base.protected_environments = protected_environments + ActiveRecord.protected_environments = protected_environments end def test_raises_an_error_if_no_migrations_have_been_made @@ -194,7 +194,7 @@ def test_with_multiple_databases env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call with_multi_db_configurations(env) do - protected_environments = ActiveRecord::Base.protected_environments + protected_environments = ActiveRecord.protected_environments current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment assert_equal current_env, env @@ -214,13 +214,13 @@ def test_with_multiple_databases schema_migration.create_table schema_migration.create_version("1") - ActiveRecord::Base.protected_environments = [current_env.to_sym] + ActiveRecord.protected_environments = [current_env.to_sym] assert_raise(ActiveRecord::ProtectedEnvironmentError) do ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!(env) end ensure - ActiveRecord::Base.protected_environments = protected_environments + ActiveRecord.protected_environments = protected_environments end end
← Back to Alerts View on GitHub →