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
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