decidim-dev 0.32.1 → 0.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/decidim/dev/dummy_resources_controller.rb +3 -1
- data/config/locales/eu.yml +6 -6
- data/config/locales/gl.yml +15 -1
- data/config/rubocop/decidim-linters/configuration.yml +36 -0
- data/config/rubocop/rspec/configuration.yml +1 -0
- data/decidim-dev.gemspec +1 -1
- data/lib/decidim/dev/assets/empty_file.png +0 -0
- data/lib/decidim/dev/assets/malformed_svg_unclosed_element.svg +7 -0
- data/lib/decidim/dev/assets/malicious_svg_animation.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_animation_attribute_names.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_animation_fill.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_animation_style.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_animation_values.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_attributename_case.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_backslash_reference.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_css_backslash_url.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_css_comment_in_url.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_css_image_set.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_css_image_set_nested_parentheses.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_cursor.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_cursor_attribute.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_data_uri_declared_encoding.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_data_uri_deep_nested.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_data_uri_leading_space.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_data_uri_nested.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_data_uri_parameter.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_entity.svg +5 -0
- data/lib/decidim/dev/assets/malicious_svg_entity_parameter.svg +7 -0
- data/lib/decidim/dev/assets/malicious_svg_external_animation.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_external_animation_missing_target.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_external_animation_target.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_external_feimage.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_external_image.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_external_use.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_fill_external_reference.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_foreign_object.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_handler.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_marker_external_reference.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_mask_attribute.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_script.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_style_comment_string.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_style_import.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_style_import_string.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_style_url_escape.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_style_url_string.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_stylesheet_pi.svg +1 -0
- data/lib/decidim/dev/assets/malicious_svg_uri.svg +1 -0
- data/lib/decidim/dev/assets/spoofed_image.png +0 -0
- data/lib/decidim/dev/assets/spoofed_image_binary.png +1 -0
- data/lib/decidim/dev/assets/spoofed_image_html.png +5 -0
- data/lib/decidim/dev/assets/spoofed_svg_xml.svg +2 -0
- data/lib/decidim/dev/assets/test.avif +0 -0
- data/lib/decidim/dev/assets/test.gif +0 -0
- data/lib/decidim/dev/assets/test.heic +0 -0
- data/lib/decidim/dev/assets/test.svg +1 -0
- data/lib/decidim/dev/assets/test_animation.svg +1 -0
- data/lib/decidim/dev/assets/test_animation_link.svg +7 -0
- data/lib/decidim/dev/assets/test_animation_presentation_reference.svg +8 -0
- data/lib/decidim/dev/assets/test_complex.svg +12 -0
- data/lib/decidim/dev/assets/test_css_references.svg +14 -0
- data/lib/decidim/dev/assets/test_data_uri_declared_encoding.svg +5 -0
- data/lib/decidim/dev/assets/test_entity_in_comment.svg +8 -0
- data/lib/decidim/dev/assets/test_entity_in_processing_instruction.svg +7 -0
- data/lib/decidim/dev/assets/test_percent_encoded_fragment.svg +7 -0
- data/lib/decidim/dev/assets/test_percent_encoded_multibyte_reference.svg +7 -0
- data/lib/decidim/dev/assets/test_percent_encoded_multibyte_reference_mixed.svg +8 -0
- data/lib/decidim/dev/assets/test_presentation_attribute_references.svg +13 -0
- data/lib/decidim/dev/assets/test_references.svg +12 -0
- data/lib/decidim/dev/assets/test_style_text.svg +9 -0
- data/lib/decidim/dev/assets/video.mp4 +0 -0
- data/lib/decidim/dev/rubocop/cop/decidim/enforce_permission_to.rb +459 -0
- data/lib/decidim/dev/rubocop/cop/decidim/organization_scoped_finder.rb +200 -0
- data/lib/decidim/dev/test/rspec_support/capybara.rb +54 -0
- data/lib/decidim/dev/test/rspec_support/editor_context.rb +1 -1
- data/lib/decidim/dev/test/rspec_support/helpers.rb +14 -1
- data/lib/decidim/dev/version.rb +1 -1
- metadata +79 -13
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Decidim
|
|
8
|
+
# Flags controller actions that lack authorization checks.
|
|
9
|
+
#
|
|
10
|
+
# In Decidim admin controllers, every action must explicitly call
|
|
11
|
+
# `enforce_permission_to` or `action_authorized_to` to verify the
|
|
12
|
+
# current user has permission to perform that action. Missing
|
|
13
|
+
# authorization checks can allow unauthorized access to resources.
|
|
14
|
+
#
|
|
15
|
+
# This cop checks all public method definitions in admin controllers
|
|
16
|
+
# and flags any that do not contain an authorization call.
|
|
17
|
+
#
|
|
18
|
+
# Actions are exempt when the controller uses a `before_action` that
|
|
19
|
+
# performs authorization (either by referencing a method that calls
|
|
20
|
+
# `enforce_permission_to`, or by using a name containing
|
|
21
|
+
# "permission", "authorize", or "enforce"). When such `before_action`
|
|
22
|
+
# is restricted with `only:` or `except:`, only the actions it covers
|
|
23
|
+
# are exempt.
|
|
24
|
+
#
|
|
25
|
+
# Methods referenced by framework lifecycle callbacks (`before_action`
|
|
26
|
+
# and its variants, `around_action`, `after_action`, `helper_method`,
|
|
27
|
+
# `rescue_from`, ...) are treated as lifecycle/helper methods and are
|
|
28
|
+
# never flagged, as they themselves are not controller actions.
|
|
29
|
+
#
|
|
30
|
+
# Calls to methods starting with `enforce_permission_to_` or
|
|
31
|
+
# `action_authorized_to_` (authorization helper wrappers) are also
|
|
32
|
+
# accepted as authorization checks.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# # bad
|
|
36
|
+
# class Admin::ResourcesController < Admin::ApplicationController
|
|
37
|
+
# def edit
|
|
38
|
+
# @resource = Resource.find(params[:id])
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # good
|
|
43
|
+
# class Admin::ResourcesController < Admin::ApplicationController
|
|
44
|
+
# def edit
|
|
45
|
+
# enforce_permission_to :update, :resource, resource:
|
|
46
|
+
# @resource = Resource.find(params[:id])
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # good - authorization via before_action
|
|
51
|
+
# class Admin::ResourcesController < Admin::ApplicationController
|
|
52
|
+
# before_action :ensure_permissions
|
|
53
|
+
#
|
|
54
|
+
# def edit
|
|
55
|
+
# @resource = Resource.find(params[:id])
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
# private
|
|
59
|
+
#
|
|
60
|
+
# def ensure_permissions
|
|
61
|
+
# enforce_permission_to :update, :resource
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
class EnforcePermissionTo < RuboCop::Cop::Base
|
|
65
|
+
MSG = "Action `%{action}` is missing an authorization check. " \
|
|
66
|
+
"Add `enforce_permission_to` or `action_authorized_to` at the start of the action, " \
|
|
67
|
+
"or use `# rubocop:disable Decidim/EnforcePermissionTo` if authorization is handled elsewhere."
|
|
68
|
+
|
|
69
|
+
AUTHORIZATION_METHODS = [:enforce_permission_to, :action_authorized_to].freeze
|
|
70
|
+
|
|
71
|
+
# Prefixes accepted for authorization helper wrappers, such as
|
|
72
|
+
# `enforce_permission_to_update_resource`.
|
|
73
|
+
AUTHORIZATION_PREFIXES = %w(enforce_permission_to_ action_authorized_to_).freeze
|
|
74
|
+
|
|
75
|
+
BEFORE_ACTION_AUTH_KEYWORDS = %w(permission authorize enforce).freeze
|
|
76
|
+
|
|
77
|
+
# Visibility switch methods recognized when declared with no arguments.
|
|
78
|
+
VISIBILITY_METHODS = [:public, :protected, :private].freeze
|
|
79
|
+
|
|
80
|
+
# Framework declarations whose referenced methods are lifecycle or
|
|
81
|
+
# helper methods rather than controller actions.
|
|
82
|
+
CALLBACK_METHODS = [
|
|
83
|
+
:before_action, :prepend_before_action, :append_before_action, :skip_before_action,
|
|
84
|
+
:around_action, :prepend_around_action, :append_around_action, :skip_around_action,
|
|
85
|
+
:after_action, :prepend_after_action, :append_after_action, :skip_after_action,
|
|
86
|
+
:helper_method, :rescue_from
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
def on_new_investigation
|
|
90
|
+
@scope_callback_methods = {}
|
|
91
|
+
reset_scope_state
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_class(node)
|
|
95
|
+
reset_scope_state
|
|
96
|
+
collect_callback_methods(node)
|
|
97
|
+
check_before_actions(node)
|
|
98
|
+
check_before_action_blocks(node)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def on_module(node)
|
|
102
|
+
reset_scope_state
|
|
103
|
+
collect_callback_methods(node)
|
|
104
|
+
check_before_actions(node)
|
|
105
|
+
check_before_action_blocks(node)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_def(node)
|
|
109
|
+
method_name = node.method_name
|
|
110
|
+
|
|
111
|
+
inline_visibility = @inline_visibility
|
|
112
|
+
@inline_visibility = nil
|
|
113
|
+
|
|
114
|
+
return if class_methods_block?(node)
|
|
115
|
+
return unless method_public?(node, method_name, inline_visibility)
|
|
116
|
+
return if action_exempted_by_before_action?(method_name)
|
|
117
|
+
return if callback_method?(node, method_name)
|
|
118
|
+
return if contains_authorization_check?(node)
|
|
119
|
+
|
|
120
|
+
action_name = method_name.to_s
|
|
121
|
+
add_offense(node, message: format(MSG, action: action_name))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def on_send(node)
|
|
125
|
+
return unless VISIBILITY_METHODS.include?(node.method_name)
|
|
126
|
+
|
|
127
|
+
if node.arguments.empty?
|
|
128
|
+
@in_private_section = node.method_name != :public
|
|
129
|
+
elsif visibility_def_argument?(node)
|
|
130
|
+
# `private def foo` only makes that single method private, it does
|
|
131
|
+
# not change the default visibility for the following methods.
|
|
132
|
+
@inline_visibility = node.method_name
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Resets the state that is scoped to a single class or module body.
|
|
139
|
+
# Callback methods are intentionally not part of this state: they are
|
|
140
|
+
# collected per scope in `collect_callback_methods`, so sibling scopes
|
|
141
|
+
# do not see each other's callbacks. A definition only inherits the
|
|
142
|
+
# callbacks of its own scope and of enclosing modules (never across a
|
|
143
|
+
# class boundary, as nested classes are not subclasses).
|
|
144
|
+
def reset_scope_state
|
|
145
|
+
@in_private_section = false
|
|
146
|
+
@inline_visibility = nil
|
|
147
|
+
@all_actions_auth = false
|
|
148
|
+
@only_actions = Set.new
|
|
149
|
+
@except_sets = []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Definitions inside an `ActiveSupport::Concern` `class_methods do`
|
|
153
|
+
# block are class-level DSL methods, not controller actions, so they
|
|
154
|
+
# must never be flagged as missing an authorization check.
|
|
155
|
+
def class_methods_block?(node)
|
|
156
|
+
node.each_ancestor(:block).any? do |block|
|
|
157
|
+
block.send_node.method_name == :class_methods
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def visibility_def_argument?(node)
|
|
162
|
+
node.arguments.any?(&:def_type?)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def method_public?(node, method_name, inline_visibility)
|
|
166
|
+
named_visibility = named_visibility_for(node, method_name)
|
|
167
|
+
return named_visibility == :public if named_visibility
|
|
168
|
+
|
|
169
|
+
visibility = inline_visibility || (@in_private_section ? :private : :public)
|
|
170
|
+
visibility == :public
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Resolves named visibility modifiers such as `public :publish` or
|
|
174
|
+
# `private :resource` that appear after the method definition. In Ruby
|
|
175
|
+
# these modifiers only apply to methods already defined, so a modifier
|
|
176
|
+
# placed before a later `def` does not change that definition's
|
|
177
|
+
# visibility. Among the applicable modifiers, the last one wins.
|
|
178
|
+
def named_visibility_for(node, method_name)
|
|
179
|
+
scope = node.each_ancestor(:class, :module).first
|
|
180
|
+
return nil unless scope
|
|
181
|
+
|
|
182
|
+
named_visibility_overrides(scope, node)[method_name]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def named_visibility_overrides(scope, node)
|
|
186
|
+
def_end = node.source_range.end_pos
|
|
187
|
+
|
|
188
|
+
scope.body&.each_child_node&.with_object({}) do |child, overrides|
|
|
189
|
+
next unless named_visibility_after?(child, def_end)
|
|
190
|
+
|
|
191
|
+
child.arguments.each do |arg|
|
|
192
|
+
covered_actions(arg).each do |name|
|
|
193
|
+
overrides[name] = child.method_name
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end || {}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def named_visibility_after?(child, def_end)
|
|
200
|
+
named_visibility_modifier?(child) && child.source_range.begin_pos > def_end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def named_visibility_modifier?(node)
|
|
204
|
+
node.send_type? && VISIBILITY_METHODS.include?(node.method_name) &&
|
|
205
|
+
!node.arguments.empty? && !visibility_def_argument?(node)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Collects the lifecycle/helper methods owned by a scope (a class or
|
|
209
|
+
# module). Only declarations and definitions belonging to the scope
|
|
210
|
+
# itself are considered; nested classes and modules are excluded so
|
|
211
|
+
# callbacks cannot leak between sibling scopes. The result is stored per
|
|
212
|
+
# scope so a definition can inherit the callbacks of its enclosing
|
|
213
|
+
# scopes without seeing those of its siblings.
|
|
214
|
+
def collect_callback_methods(node)
|
|
215
|
+
return unless node.body
|
|
216
|
+
|
|
217
|
+
definitions = own_scope_definitions(node)
|
|
218
|
+
declared = own_scope_callback_names(node)
|
|
219
|
+
@scope_callback_methods[node] = expand_callback_dependencies(declared, definitions)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The methods referenced by callback declarations on the scope itself.
|
|
223
|
+
def own_scope_callback_names(node)
|
|
224
|
+
own_scope_nodes(node).each_with_object(Set.new) do |descendant, names|
|
|
225
|
+
next unless descendant.send_type?
|
|
226
|
+
next unless CALLBACK_METHODS.include?(descendant.method_name)
|
|
227
|
+
|
|
228
|
+
callback_method_names(descendant).each { |name| names << name }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The method definitions belonging to the scope itself, indexed by name.
|
|
233
|
+
def own_scope_definitions(node)
|
|
234
|
+
own_scope_nodes(node).each_with_object({}) do |descendant, definitions|
|
|
235
|
+
next unless descendant.def_type?
|
|
236
|
+
|
|
237
|
+
definitions[descendant.method_name] ||= descendant
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Walks a scope body without descending into nested classes or modules,
|
|
242
|
+
# so each scope only sees its own declarations and definitions.
|
|
243
|
+
def own_scope_nodes(node)
|
|
244
|
+
nodes = []
|
|
245
|
+
queue = [node.body].compact
|
|
246
|
+
|
|
247
|
+
until queue.empty?
|
|
248
|
+
current = queue.shift
|
|
249
|
+
next if current.class_type? || current.module_type?
|
|
250
|
+
|
|
251
|
+
nodes << current
|
|
252
|
+
current.each_child_node { |child| queue << child }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
nodes
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Methods called by a lifecycle callback are helper methods as well,
|
|
259
|
+
# e.g. a callback delegating to a public helper method.
|
|
260
|
+
def expand_callback_dependencies(declared, definitions)
|
|
261
|
+
names = Set.new(declared)
|
|
262
|
+
queue = declared.to_a
|
|
263
|
+
|
|
264
|
+
until queue.empty?
|
|
265
|
+
definition = definitions[queue.shift]
|
|
266
|
+
next unless definition&.body
|
|
267
|
+
|
|
268
|
+
sends = [definition.body]
|
|
269
|
+
sends.concat(definition.body.each_descendant(:send).to_a)
|
|
270
|
+
|
|
271
|
+
sends.each do |send_node|
|
|
272
|
+
next unless send_node.send_type?
|
|
273
|
+
next unless controller_call?(send_node)
|
|
274
|
+
|
|
275
|
+
callee = send_node.method_name
|
|
276
|
+
next unless definitions.has_key?(callee)
|
|
277
|
+
next unless names.add?(callee)
|
|
278
|
+
|
|
279
|
+
queue << callee
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
names
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Whether the method is a lifecycle/helper method of the def's own scope
|
|
287
|
+
# or of an enclosing module. Callbacks declared in a class are not
|
|
288
|
+
# inherited by definitions inside a nested class: Ruby does not treat a
|
|
289
|
+
# nested class as a subclass, so an outer controller's `helper_method`
|
|
290
|
+
# must not exempt an unrelated nested controller's action with the same
|
|
291
|
+
# name. Enclosing modules are still followed (a common concern pattern),
|
|
292
|
+
# but the walk stops at the first class boundary.
|
|
293
|
+
def callback_method?(node, method_name)
|
|
294
|
+
scopes = node.each_ancestor(:class, :module).to_a
|
|
295
|
+
return false if scopes.empty?
|
|
296
|
+
|
|
297
|
+
own_scope = scopes.shift
|
|
298
|
+
return true if scope_callback?(own_scope, method_name)
|
|
299
|
+
|
|
300
|
+
scopes.each do |scope|
|
|
301
|
+
break if scope.class_type?
|
|
302
|
+
|
|
303
|
+
return true if scope_callback?(scope, method_name)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
false
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def scope_callback?(scope, method_name)
|
|
310
|
+
@scope_callback_methods.fetch(scope, Set.new).include?(method_name)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A call is made on the controller itself when the receiver is implicit
|
|
314
|
+
# or an explicit `self`. Calls with any other receiver, such as
|
|
315
|
+
# `Resource.find`, must not be followed because a same-named controller
|
|
316
|
+
# action could then be mistaken for a lifecycle/helper method, hiding a
|
|
317
|
+
# missing authorization check.
|
|
318
|
+
def controller_call?(send_node)
|
|
319
|
+
send_node.receiver.nil? || send_node.receiver.self_type?
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def callback_method_names(send_node)
|
|
323
|
+
send_node.arguments.flat_map do |arg|
|
|
324
|
+
case arg.type
|
|
325
|
+
when :sym, :str
|
|
326
|
+
arg.value.to_sym
|
|
327
|
+
when :hash
|
|
328
|
+
rescue_handler_names(arg)
|
|
329
|
+
else
|
|
330
|
+
[]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# `rescue_from SomeError, with: :handler` references the handler
|
|
336
|
+
# method through the `with:` option.
|
|
337
|
+
def rescue_handler_names(hash_node)
|
|
338
|
+
hash_node.pairs.filter_map do |pair|
|
|
339
|
+
next unless pair.key.sym_type? && pair.key.value == :with
|
|
340
|
+
|
|
341
|
+
value = pair.value
|
|
342
|
+
value.value.to_sym if value.sym_type? || value.str_type?
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def check_before_actions(class_node)
|
|
347
|
+
class_node.body&.each_child_node(:send) do |send_node|
|
|
348
|
+
next unless send_node.method_name == :before_action
|
|
349
|
+
|
|
350
|
+
register_auth_before_action(send_node) if before_action_handles_auth?(send_node)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def check_before_action_blocks(class_node)
|
|
355
|
+
class_node.body&.each_descendant(:block) do |block_node|
|
|
356
|
+
next unless block_node.send_node.method_name == :before_action
|
|
357
|
+
|
|
358
|
+
register_auth_before_action(block_node.send_node) if block_contains_auth?(block_node)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def before_action_handles_auth?(node)
|
|
363
|
+
node.arguments.any? do |arg|
|
|
364
|
+
auth_symbol_argument?(arg)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def register_auth_before_action(node)
|
|
369
|
+
only, except = action_restrictions(node)
|
|
370
|
+
|
|
371
|
+
if only
|
|
372
|
+
only -= except if except
|
|
373
|
+
@only_actions.merge(only)
|
|
374
|
+
elsif except
|
|
375
|
+
@except_sets << except
|
|
376
|
+
else
|
|
377
|
+
@all_actions_auth = true
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def action_exempted_by_before_action?(method_name)
|
|
382
|
+
return true if @all_actions_auth
|
|
383
|
+
return true if @only_actions.include?(method_name)
|
|
384
|
+
return true if @except_sets.any? { |actions| actions.none?(method_name) }
|
|
385
|
+
|
|
386
|
+
false
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def action_restrictions(node)
|
|
390
|
+
only = nil
|
|
391
|
+
except = nil
|
|
392
|
+
|
|
393
|
+
node.arguments.each do |arg|
|
|
394
|
+
next unless arg.hash_type?
|
|
395
|
+
|
|
396
|
+
arg.pairs.each do |pair|
|
|
397
|
+
next unless pair.key.sym_type?
|
|
398
|
+
|
|
399
|
+
case pair.key.value
|
|
400
|
+
when :only
|
|
401
|
+
only = covered_actions(pair.value)
|
|
402
|
+
when :except
|
|
403
|
+
except = covered_actions(pair.value)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
[only, except]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def covered_actions(value)
|
|
412
|
+
nodes = value.array_type? ? value.children : [value]
|
|
413
|
+
|
|
414
|
+
nodes.select { |node| node.sym_type? || node.str_type? }
|
|
415
|
+
.to_set { |node| node.value.to_sym }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def block_contains_auth?(block_node)
|
|
419
|
+
body = block_node.body
|
|
420
|
+
return false unless body
|
|
421
|
+
|
|
422
|
+
return true if authorization_call?(body)
|
|
423
|
+
|
|
424
|
+
body.each_descendant(:send).any? do |send_node|
|
|
425
|
+
authorization_call?(send_node)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def auth_symbol_argument?(arg)
|
|
430
|
+
return false unless arg.sym_type?
|
|
431
|
+
|
|
432
|
+
name = arg.value.to_s
|
|
433
|
+
BEFORE_ACTION_AUTH_KEYWORDS.any? { |keyword| name.include?(keyword) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def contains_authorization_check?(node)
|
|
437
|
+
return false unless node.body
|
|
438
|
+
|
|
439
|
+
body = node.body
|
|
440
|
+
|
|
441
|
+
return true if authorization_call?(body)
|
|
442
|
+
|
|
443
|
+
body.each_descendant(:send).any? do |send_node|
|
|
444
|
+
authorization_call?(send_node)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def authorization_call?(send_node)
|
|
449
|
+
return false unless send_node.send_type?
|
|
450
|
+
|
|
451
|
+
return true if AUTHORIZATION_METHODS.include?(send_node.method_name)
|
|
452
|
+
|
|
453
|
+
method_name = send_node.method_name.to_s
|
|
454
|
+
AUTHORIZATION_PREFIXES.any? { |prefix| method_name.start_with?(prefix) }
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Decidim
|
|
8
|
+
# Flags unscoped ActiveRecord finder calls in controllers.
|
|
9
|
+
#
|
|
10
|
+
# In a multi-tenant Decidim deployment, loading a record without scoping
|
|
11
|
+
# it to the current organization can allow one tenant's users to access
|
|
12
|
+
# another tenant's resources. This cop promotes safe patterns such as
|
|
13
|
+
# `current_organization.templates.find_by(id: ...)` or using an already
|
|
14
|
+
# scoped `collection`.
|
|
15
|
+
#
|
|
16
|
+
# The cop targets chains that start with an ActiveRecord model constant
|
|
17
|
+
# and end with a terminal finder (`find`, `find_by`, `find_by!`, `first`,
|
|
18
|
+
# `take`, etc.) unless the chain includes a recognized scoping step.
|
|
19
|
+
class OrganizationScopedFinder < RuboCop::Cop::Base
|
|
20
|
+
MSG = "Unscoped ActiveRecord finder detected. Scope the query to the current organization, " \
|
|
21
|
+
"e.g. `current_organization.<relation>.find_by(id: params[:id])` or use an already-scoped `collection`."
|
|
22
|
+
|
|
23
|
+
FINDER_METHODS = [
|
|
24
|
+
:find, :find_by, :find_by!, :find_sole_by, :first, :first!, :last, :last!,
|
|
25
|
+
:sole, :sole!, :take, :take!,
|
|
26
|
+
:find_or_initialize_by, :find_or_create_by, :find_or_create_by!
|
|
27
|
+
].freeze
|
|
28
|
+
RESTRICT_ON_SEND = [*FINDER_METHODS, :where].freeze
|
|
29
|
+
|
|
30
|
+
SCOPE_ROOT_METHODS = [:current_organization, :collection].freeze
|
|
31
|
+
|
|
32
|
+
SCOPED_WHERE_KEYS = [:current_organization, :current_component].freeze
|
|
33
|
+
|
|
34
|
+
ORGANIZATION_SCOPED_KEYS = [:decidim_organization_id, :organization_id].freeze
|
|
35
|
+
|
|
36
|
+
SCOPED_ASSOCIATION_KEYS = [:component, :organization, :participatory_space].freeze
|
|
37
|
+
|
|
38
|
+
SCOPED_HELPER_METHODS = [:current_organization, :current_component, :current_participatory_space].freeze
|
|
39
|
+
|
|
40
|
+
IGNORED_CONSTANT_PREFIXES = %w(Bundler ActiveRecord).freeze
|
|
41
|
+
|
|
42
|
+
def on_send(node)
|
|
43
|
+
return unless RESTRICT_ON_SEND.include?(node.method_name)
|
|
44
|
+
return if node.block_node
|
|
45
|
+
return if scoped_finder_arguments?(node)
|
|
46
|
+
return unless node.receiver
|
|
47
|
+
return if organization_scoped?(node.receiver)
|
|
48
|
+
return unless unscoped_model_class?(node.receiver)
|
|
49
|
+
return if constructed_scope_root?(node.receiver)
|
|
50
|
+
|
|
51
|
+
return if consumed_by_finder?(node)
|
|
52
|
+
|
|
53
|
+
add_offense(node)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def organization_scoped?(node)
|
|
59
|
+
return false unless node
|
|
60
|
+
return true if scoped_node?(node)
|
|
61
|
+
return organization_scoped?(node.receiver) if node.send_type?
|
|
62
|
+
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unscoped_model_class?(node)
|
|
67
|
+
return false unless node
|
|
68
|
+
return false if ignored_constant?(node)
|
|
69
|
+
return true if node.const_type?
|
|
70
|
+
return false unless node.send_type?
|
|
71
|
+
|
|
72
|
+
unscoped_model_class?(node.receiver)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ignored_constant?(node)
|
|
76
|
+
node.const_type? &&
|
|
77
|
+
IGNORED_CONSTANT_PREFIXES.any? { |prefix| node.source.start_with?(prefix) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scoped_node?(node)
|
|
81
|
+
return false unless node.send_type?
|
|
82
|
+
|
|
83
|
+
scope_root?(node) || scoped_where?(node) || scoped_finder_arguments?(node)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def scope_root?(node)
|
|
87
|
+
return false unless SCOPE_ROOT_METHODS.include?(node.method_name)
|
|
88
|
+
|
|
89
|
+
node.receiver.nil? || node.receiver.self_type?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scoped_finder_arguments?(node)
|
|
93
|
+
node.arguments.any? { |arg| scoped_argument?(arg) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def scoped_where?(node)
|
|
97
|
+
return false unless node.method_name == :where
|
|
98
|
+
|
|
99
|
+
node.arguments.any? { |arg| scoped_argument?(arg) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def scoped_argument?(node)
|
|
103
|
+
return false unless node.hash_type?
|
|
104
|
+
|
|
105
|
+
node.children.any? { |pair| scoped_pair?(pair) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def scoped_pair?(pair_node)
|
|
109
|
+
return false unless pair_node.pair_type?
|
|
110
|
+
|
|
111
|
+
key = pair_node.children[0]
|
|
112
|
+
value = pair_node.children[1]
|
|
113
|
+
|
|
114
|
+
scoped_where_pair?(key, value) ||
|
|
115
|
+
scoped_association_pair?(key, value) ||
|
|
116
|
+
(value.hash_type? && value.children.any? { |inner| ownership_scoped_pair?(inner) })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ownership_scoped_pair?(pair_node)
|
|
120
|
+
return false unless pair_node.pair_type?
|
|
121
|
+
|
|
122
|
+
key = pair_node.children[0]
|
|
123
|
+
value = pair_node.children[1]
|
|
124
|
+
|
|
125
|
+
scoped_association_pair?(key, value) ||
|
|
126
|
+
(value.hash_type? && value.children.any? { |inner| ownership_scoped_pair?(inner) })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def scoped_where_pair?(key, value)
|
|
130
|
+
return false unless key.sym_type?
|
|
131
|
+
return false unless scoped_where_key?(key)
|
|
132
|
+
|
|
133
|
+
scoped_trusted_value?(value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def scoped_association_pair?(key, value)
|
|
137
|
+
return false unless key.sym_type?
|
|
138
|
+
return false unless SCOPED_ASSOCIATION_KEYS.include?(key.value)
|
|
139
|
+
return false unless value.send_type?
|
|
140
|
+
return false if value.receiver
|
|
141
|
+
|
|
142
|
+
value.method_name == :"current_#{key.value}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def scoped_trusted_value?(node)
|
|
146
|
+
return false unless node
|
|
147
|
+
|
|
148
|
+
case node.type
|
|
149
|
+
when :send
|
|
150
|
+
scoped_helper_method?(node) || (node.receiver && scoped_trusted_value?(node.receiver))
|
|
151
|
+
when :hash
|
|
152
|
+
node.children.any? { |pair| scoped_pair?(pair) }
|
|
153
|
+
else
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def scoped_helper_method?(node)
|
|
159
|
+
return false unless node.send_type?
|
|
160
|
+
return false if node.receiver
|
|
161
|
+
|
|
162
|
+
SCOPED_HELPER_METHODS.include?(node.method_name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def scoped_where_key?(key)
|
|
166
|
+
key.sym_type? &&
|
|
167
|
+
(SCOPED_WHERE_KEYS.include?(key.value) ||
|
|
168
|
+
ORGANIZATION_SCOPED_KEYS.include?(key.value))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def constructed_scope_root?(node)
|
|
172
|
+
return false unless node.send_type?
|
|
173
|
+
|
|
174
|
+
if node.method_name == :new
|
|
175
|
+
return false unless node.receiver&.const_type?
|
|
176
|
+
|
|
177
|
+
return scoped_finder_arguments?(node)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
constructed_scope_root?(node.receiver) if node.receiver
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def consumed_by_finder?(node)
|
|
184
|
+
return false unless node.method_name == :where
|
|
185
|
+
|
|
186
|
+
ancestor = node.parent
|
|
187
|
+
while ancestor&.send_type?
|
|
188
|
+
break unless ancestor.receiver == node
|
|
189
|
+
return true if FINDER_METHODS.include?(ancestor.method_name)
|
|
190
|
+
|
|
191
|
+
node = ancestor
|
|
192
|
+
ancestor = node.parent
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|