Environment-based destructive action protection / API surface hardening

MEDIUM
rails/rails
Commit: 442408e26a06
Affected: 8.1.0 - 8.1.2
2026-04-05 12:44 UTC

Description

This commit centralizes the protected_environments API by moving it from ActiveRecord::Base to ActiveRecord, defaulting to production, and introducing deprecation warnings for older usage. This hardens protection against destructive migrations by ensuring a single, canonical source of truth for environment-based restrictions and guiding developers to migrate usage. It reduces the risk of accidentally allowing destructive actions in non-production environments due to API scoping mistakes or legacy code paths. The tests are updated to reflect the new API usage and to signal deprecation for the old path.

Commit Details

Author: Hartley McGuire

Date: 2026-02-24 13:58 UTC

Message:

Merge pull request #56749 from skipkayhil/hm-nymvutosvmtpqvny Move protected_environments to ActiveRecord module

Triage Assessment

Vulnerability Type: Destructive action protection (environment-based) / Privilege and safety hardening

Confidence: MEDIUM

Reasoning:

The commit centralizes and enforces protection of destructive actions via protected_environments, defaulting to production. It also refactors how the protected_environments API is accessed (ActiveRecord.protected_environments) and adds deprecation warnings for older usage, indicating a security-related API change to prevent destructive migrations in non-production environments.

Verification Assessment

Vulnerability Type: Environment-based destructive action protection / API surface hardening

Confidence: MEDIUM

Affected Versions: 8.1.0 - 8.1.2

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 →