Skip to content

Commit 2922e33

Browse files
deivid-rodriguezlukas-euNarnach
authored
Require explict allowlisting of attributes and associations (activerecord-hackery#1400)
Co-authored-by: lukas-eu <62448426+lukas-eu@users.noreply.github.com> Co-authored-by: Wes Oldenbeuving <wes@narnach.com>
1 parent 784e600 commit 2922e33

4 files changed

Lines changed: 182 additions & 17 deletions

File tree

‎bug_report_templates/test-ransacker-arel-present-predicate.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ class Project < ActiveRecord::Base
5656
ransacker :number do |parent|
5757
parent.table[:number]
5858
end
59+
60+
def self.ransackable_attributes(_auth_object = nil)
61+
["name", "number"]
62+
end
5963
end
6064

6165
class BugTest < Minitest::Test

‎lib/ransack/adapters/active_record/base.rb‎

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,15 @@ def ransack_alias(new_name, old_name)
3535
# For overriding with a whitelist array of strings.
3636
#
3737
def ransackable_attributes(auth_object = nil)
38-
@ransackable_attributes ||= if Ransack::SUPPORTS_ATTRIBUTE_ALIAS
39-
column_names + _ransackers.keys + _ransack_aliases.keys +
40-
attribute_aliases.keys
41-
else
42-
column_names + _ransackers.keys + _ransack_aliases.keys
43-
end
38+
@ransackable_attributes ||= deprecated_ransackable_list(:ransackable_attributes)
4439
end
4540

4641
# Ransackable_associations, by default, returns the names
4742
# of all associations as an array of strings.
4843
# For overriding with a whitelist array of strings.
4944
#
5045
def ransackable_associations(auth_object = nil)
51-
@ransackable_associations ||= reflect_on_all_associations.map { |a| a.name.to_s }
46+
@ransackable_associations ||= deprecated_ransackable_list(:ransackable_associations)
5247
end
5348

5449
# Ransortable_attributes, by default, returns the names
@@ -75,6 +70,82 @@ def ransackable_scopes_skip_sanitize_args
7570
[]
7671
end
7772

73+
# Bare list of all potentially searchable attributes. Searchable attributes
74+
# need to be explicitly allowlisted through the `ransackable_attributes`
75+
# method in each model, but if you're allowing almost everything to be
76+
# searched, this list can be used as a base for exclusions.
77+
#
78+
def authorizable_ransackable_attributes
79+
if Ransack::SUPPORTS_ATTRIBUTE_ALIAS
80+
column_names + _ransackers.keys + _ransack_aliases.keys +
81+
attribute_aliases.keys
82+
else
83+
column_names + _ransackers.keys + _ransack_aliases.keys
84+
end.uniq
85+
end
86+
87+
# Bare list of all potentially searchable associations. Searchable
88+
# associations need to be explicitly allowlisted through the
89+
# `ransackable_associations` method in each model, but if you're
90+
# allowing almost everything to be searched, this list can be used as a
91+
# base for exclusions.
92+
#
93+
def authorizable_ransackable_associations
94+
reflect_on_all_associations.map { |a| a.name.to_s }
95+
end
96+
97+
private
98+
99+
def deprecated_ransackable_list(method)
100+
list_type = method.to_s.delete_prefix("ransackable_")
101+
102+
if explicitly_defined?(method)
103+
warn_deprecated <<~ERROR
104+
Ransack's builtin `#{method}` method is deprecated and will result
105+
in an error in the future. If you want to authorize the full list
106+
of searchable #{list_type} for this model, use
107+
`authorizable_#{method}` instead of delegating to `super`.
108+
ERROR
109+
110+
public_send("authorizable_#{method}")
111+
else
112+
raise <<~MESSAGE
113+
Ransack needs #{name} #{list_type} explicitly allowlisted as
114+
searchable. Define a `#{method}` class method in your `#{name}`
115+
model, watching out for items you DON'T want searchable (for
116+
example, `encrypted_password`, `password_reset_token`, `owner` or
117+
other sensitive information). You can use the following as a base:
118+
119+
```ruby
120+
class #{name} < ApplicationRecord
121+
122+
# ...
123+
124+
def self.#{method}(auth_object = nil)
125+
#{public_send("authorizable_#{method}").sort.inspect}
126+
end
127+
128+
# ...
129+
130+
end
131+
```
132+
MESSAGE
133+
end
134+
end
135+
136+
def explicitly_defined?(method)
137+
definer_ancestor = singleton_class.ancestors.find do |ancestor|
138+
ancestor.instance_methods(false).include?(method)
139+
end
140+
141+
definer_ancestor != Ransack::Adapters::ActiveRecord::Base
142+
end
143+
144+
def warn_deprecated(message)
145+
caller_location = caller_locations.find { |location| !location.path.start_with?(File.expand_path("../..", __dir__)) }
146+
147+
warn "DEPRECATION WARNING: #{message.squish} (called at #{caller_location.path}:#{caller_location.lineno})"
148+
end
78149
end
79150
end
80151
end

‎spec/ransack/adapters/active_record/base_spec.rb‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,37 @@ def self.simple_escaping?
657657
it { should_not include 'only_sort' }
658658
it { should include 'only_admin' }
659659
end
660+
661+
context 'when not defined in model, nor in ApplicationRecord' do
662+
subject { Article.ransackable_attributes }
663+
664+
it "raises a helpful error" do
665+
without_application_record_method(:ransackable_attributes) do
666+
expect { subject }.to raise_error(RuntimeError, /Ransack needs Article attributes explicitly allowlisted/)
667+
end
668+
end
669+
end
670+
671+
context 'when defined only in model by delegating to super' do
672+
subject { Article.ransackable_attributes }
673+
674+
around do |example|
675+
Article.singleton_class.define_method(:ransackable_attributes) do
676+
super(nil) - super(nil)
677+
end
678+
679+
example.run
680+
ensure
681+
Article.singleton_class.remove_method(:ransackable_attributes)
682+
end
683+
684+
it "returns the allowlist in the model, and warns" do
685+
without_application_record_method(:ransackable_attributes) do
686+
expect { subject }.to output(/Ransack's builtin `ransackable_attributes` method is deprecated/).to_stderr
687+
expect(subject).to be_empty
688+
end
689+
end
690+
end
660691
end
661692

662693
describe '#ransortable_attributes' do
@@ -689,6 +720,37 @@ def self.simple_escaping?
689720
it { should include 'parent' }
690721
it { should include 'children' }
691722
it { should include 'articles' }
723+
724+
context 'when not defined in model, nor in ApplicationRecord' do
725+
subject { Article.ransackable_associations }
726+
727+
it "raises a helpful error" do
728+
without_application_record_method(:ransackable_associations) do
729+
expect { subject }.to raise_error(RuntimeError, /Ransack needs Article associations explicitly allowlisted/)
730+
end
731+
end
732+
end
733+
734+
context 'when defined only in model by delegating to super' do
735+
subject { Article.ransackable_associations }
736+
737+
around do |example|
738+
Article.singleton_class.define_method(:ransackable_associations) do
739+
super(nil) - super(nil)
740+
end
741+
742+
example.run
743+
ensure
744+
Article.singleton_class.remove_method(:ransackable_associations)
745+
end
746+
747+
it "returns the allowlist in the model, and warns" do
748+
without_application_record_method(:ransackable_associations) do
749+
expect { subject }.to output(/Ransack's builtin `ransackable_associations` method is deprecated/).to_stderr
750+
expect(subject).to be_empty
751+
end
752+
end
753+
end
692754
end
693755

694756
describe '#ransackable_scopes' do
@@ -704,6 +766,17 @@ def self.simple_escaping?
704766
end
705767

706768
private
769+
770+
def without_application_record_method(method)
771+
ApplicationRecord.singleton_class.alias_method :"original_#{method}", :"#{method}"
772+
ApplicationRecord.singleton_class.remove_method :"#{method}"
773+
774+
yield
775+
ensure
776+
ApplicationRecord.singleton_class.alias_method :"#{method}", :"original_#{method}"
777+
ApplicationRecord.singleton_class.remove_method :"original_#{method}"
778+
end
779+
707780
def rails7_and_mysql
708781
::ActiveRecord::VERSION::MAJOR >= 7 && ENV['DB'] == 'mysql'
709782
end

‎spec/support/schema.rb‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,24 @@
2828
)
2929
end
3030

31-
class Person < ActiveRecord::Base
31+
# This is just a test app with no sensitive data, so we explicitly allowlist all
32+
# attributes and associations for search. In general, end users should
33+
# explicitly authorize each model, but this shows a way to configure the
34+
# unrestricted default behavior of versions prior to Ransack 4.
35+
#
36+
class ApplicationRecord < ActiveRecord::Base
37+
self.abstract_class = true
38+
39+
def self.ransackable_attributes(auth_object = nil)
40+
authorizable_ransackable_attributes
41+
end
42+
43+
def self.ransackable_associations(auth_object = nil)
44+
authorizable_ransackable_associations
45+
end
46+
end
47+
48+
class Person < ApplicationRecord
3249
default_scope { order(id: :desc) }
3350
belongs_to :parent, class_name: 'Person', foreign_key: :parent_id
3451
has_many :children, class_name: 'Person', foreign_key: :parent_id
@@ -111,9 +128,9 @@ class Person < ActiveRecord::Base
111128

112129
def self.ransackable_attributes(auth_object = nil)
113130
if auth_object == :admin
114-
super - ['only_sort']
131+
authorizable_ransackable_attributes - ['only_sort']
115132
else
116-
super - ['only_sort', 'only_admin']
133+
authorizable_ransackable_attributes - ['only_sort', 'only_admin']
117134
end
118135
end
119136

@@ -129,7 +146,7 @@ def self.ransortable_attributes(auth_object = nil)
129146
class Musician < Person
130147
end
131148

132-
class Article < ActiveRecord::Base
149+
class Article < ApplicationRecord
133150
belongs_to :person
134151
has_many :comments
135152
has_and_belongs_to_many :tags
@@ -182,7 +199,7 @@ class Article < ActiveRecord::Base
182199
class StoryArticle < Article
183200
end
184201

185-
class Recommendation < ActiveRecord::Base
202+
class Recommendation < ApplicationRecord
186203
belongs_to :person
187204
belongs_to :target_person, class_name: 'Person'
188205
belongs_to :article
@@ -200,22 +217,22 @@ class Article < ::Article
200217
end
201218
end
202219

203-
class Comment < ActiveRecord::Base
220+
class Comment < ApplicationRecord
204221
belongs_to :article
205222
belongs_to :person
206223

207224
default_scope { where(disabled: false) }
208225
end
209226

210-
class Tag < ActiveRecord::Base
227+
class Tag < ApplicationRecord
211228
has_and_belongs_to_many :articles
212229
end
213230

214-
class Note < ActiveRecord::Base
231+
class Note < ApplicationRecord
215232
belongs_to :notable, polymorphic: true
216233
end
217234

218-
class Account < ActiveRecord::Base
235+
class Account < ApplicationRecord
219236
belongs_to :agent_account, class_name: "Account"
220237
belongs_to :trade_account, class_name: "Account"
221238
end
@@ -308,7 +325,7 @@ def self.create
308325
end
309326

310327
module SubDB
311-
class Base < ActiveRecord::Base
328+
class Base < ApplicationRecord
312329
self.abstract_class = true
313330
establish_connection(
314331
adapter: 'sqlite3',

0 commit comments

Comments
 (0)