durable_parameters 0.2.3
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +853 -0
- data/Rakefile +29 -0
- data/app/params/account_params.rb.example +38 -0
- data/app/params/application_params.rb +16 -0
- data/lib/durable_parameters/adapters/hanami.rb +138 -0
- data/lib/durable_parameters/adapters/rage.rb +124 -0
- data/lib/durable_parameters/adapters/rails.rb +280 -0
- data/lib/durable_parameters/adapters/sinatra.rb +91 -0
- data/lib/durable_parameters/core/application_params.rb +334 -0
- data/lib/durable_parameters/core/configuration.rb +83 -0
- data/lib/durable_parameters/core/forbidden_attributes_protection.rb +48 -0
- data/lib/durable_parameters/core/parameters.rb +643 -0
- data/lib/durable_parameters/core/params_registry.rb +110 -0
- data/lib/durable_parameters/core.rb +15 -0
- data/lib/durable_parameters/log_subscriber.rb +34 -0
- data/lib/durable_parameters/railtie.rb +65 -0
- data/lib/durable_parameters/version.rb +7 -0
- data/lib/durable_parameters.rb +41 -0
- data/lib/generators/rails/USAGE +12 -0
- data/lib/generators/rails/durable_parameters_controller_generator.rb +17 -0
- data/lib/generators/rails/templates/controller.rb +94 -0
- data/lib/legacy/action_controller/application_params.rb +235 -0
- data/lib/legacy/action_controller/parameters.rb +524 -0
- data/lib/legacy/action_controller/params_registry.rb +108 -0
- data/lib/legacy/active_model/forbidden_attributes_protection.rb +40 -0
- data/test/action_controller_required_params_test.rb +36 -0
- data/test/action_controller_tainted_params_test.rb +29 -0
- data/test/active_model_mass_assignment_taint_protection_test.rb +25 -0
- data/test/application_params_array_test.rb +245 -0
- data/test/application_params_edge_cases_test.rb +361 -0
- data/test/application_params_test.rb +893 -0
- data/test/controller_generator_test.rb +31 -0
- data/test/core_parameters_test.rb +2376 -0
- data/test/durable_parameters_test.rb +115 -0
- data/test/enhanced_error_messages_test.rb +120 -0
- data/test/gemfiles/Gemfile.rails-3.0.x +14 -0
- data/test/gemfiles/Gemfile.rails-3.1.x +14 -0
- data/test/gemfiles/Gemfile.rails-3.2.x +14 -0
- data/test/log_on_unpermitted_params_test.rb +49 -0
- data/test/metadata_validation_test.rb +294 -0
- data/test/multi_parameter_attributes_test.rb +38 -0
- data/test/parameters_core_methods_test.rb +503 -0
- data/test/parameters_integration_test.rb +553 -0
- data/test/parameters_permit_test.rb +491 -0
- data/test/parameters_require_test.rb +9 -0
- data/test/parameters_taint_test.rb +98 -0
- data/test/params_registry_concurrency_test.rb +422 -0
- data/test/params_registry_test.rb +112 -0
- data/test/permit_by_model_test.rb +227 -0
- data/test/raise_on_unpermitted_params_test.rb +32 -0
- data/test/test_helper.rb +38 -0
- data/test/transform_params_edge_cases_test.rb +526 -0
- data/test/transformation_test.rb +360 -0
- metadata +223 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'bigdecimal'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
require 'active_support/concern'
|
|
8
|
+
require 'active_support/core_ext/hash/indifferent_access'
|
|
9
|
+
require 'active_support/core_ext/array/wrap'
|
|
10
|
+
require 'action_controller'
|
|
11
|
+
require 'action_dispatch/http/upload'
|
|
12
|
+
|
|
13
|
+
module ActionController
|
|
14
|
+
# Exception raised when a required parameter is missing or empty.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# params.require(:user)
|
|
18
|
+
# # => ActionController::ParameterMissing: param is missing or the value is empty: user
|
|
19
|
+
class ParameterMissing < IndexError
|
|
20
|
+
# @return [Symbol, String] the name of the missing parameter
|
|
21
|
+
attr_reader :param
|
|
22
|
+
|
|
23
|
+
# Initialize a new ParameterMissing exception
|
|
24
|
+
#
|
|
25
|
+
# @param param [Symbol, String] the name of the missing parameter
|
|
26
|
+
def initialize(param)
|
|
27
|
+
@param = param
|
|
28
|
+
super("param is missing or the value is empty: #{param}")
|
|
29
|
+
end
|
|
30
|
+
end unless defined?(ParameterMissing)
|
|
31
|
+
|
|
32
|
+
# Exception raised when unpermitted parameters are detected and configured to raise.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# params.permit(:name)
|
|
36
|
+
# # With params containing :admin => true
|
|
37
|
+
# # => ActionController::UnpermittedParameters: found unpermitted parameters: admin
|
|
38
|
+
class UnpermittedParameters < IndexError
|
|
39
|
+
# @return [Array<String>] the names of the unpermitted parameters
|
|
40
|
+
attr_reader :params
|
|
41
|
+
|
|
42
|
+
# Initialize a new UnpermittedParameters exception
|
|
43
|
+
#
|
|
44
|
+
# @param params [Array<String>] the names of the unpermitted parameters
|
|
45
|
+
def initialize(params)
|
|
46
|
+
@params = params
|
|
47
|
+
super("found unpermitted parameters: #{params.join(', ')}")
|
|
48
|
+
end
|
|
49
|
+
end unless defined?(UnpermittedParameters)
|
|
50
|
+
|
|
51
|
+
# Strong Parameters implementation for Action Controller.
|
|
52
|
+
#
|
|
53
|
+
# This class provides a whitelist-based approach to mass assignment protection,
|
|
54
|
+
# requiring explicit permission for parameters before they can be used in
|
|
55
|
+
# Active Model mass assignments.
|
|
56
|
+
#
|
|
57
|
+
# @example Basic usage
|
|
58
|
+
# params = ActionController::Parameters.new(user: { name: 'John', admin: true })
|
|
59
|
+
# params.require(:user).permit(:name)
|
|
60
|
+
# # => <ActionController::Parameters {"name"=>"John"} permitted: true>
|
|
61
|
+
class Parameters < ActiveSupport::HashWithIndifferentAccess
|
|
62
|
+
# @return [Boolean] whether this Parameters object is permitted
|
|
63
|
+
attr_accessor :permitted
|
|
64
|
+
alias permitted? permitted
|
|
65
|
+
|
|
66
|
+
# @return [Symbol, String, nil] the key used in the last require() call
|
|
67
|
+
attr_accessor :required_key
|
|
68
|
+
|
|
69
|
+
cattr_accessor :action_on_unpermitted_parameters, instance_accessor: false
|
|
70
|
+
|
|
71
|
+
# Parameters that are never considered unpermitted.
|
|
72
|
+
# These are added by Rails and are of no concern for security.
|
|
73
|
+
NEVER_UNPERMITTED_PARAMS = %w[controller action].freeze
|
|
74
|
+
|
|
75
|
+
# Initialize a new Parameters object.
|
|
76
|
+
#
|
|
77
|
+
# @param attributes [Hash, nil] the initial attributes
|
|
78
|
+
def initialize(attributes = nil)
|
|
79
|
+
super(attributes)
|
|
80
|
+
@permitted = false
|
|
81
|
+
@required_key = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Mark this Parameters object and all nested Parameters as permitted.
|
|
85
|
+
#
|
|
86
|
+
# Use with extreme caution as this allows all current and future attributes
|
|
87
|
+
# to be mass-assigned. Should only be used when you fully trust the source
|
|
88
|
+
# of the parameters.
|
|
89
|
+
#
|
|
90
|
+
# @return [Parameters] self
|
|
91
|
+
# @example
|
|
92
|
+
# params.require(:log_entry).permit!
|
|
93
|
+
def permit!
|
|
94
|
+
each_pair do |key, value|
|
|
95
|
+
value = convert_hashes_to_parameters(key, value)
|
|
96
|
+
Array.wrap(value).each do |_|
|
|
97
|
+
_.permit! if _.respond_to?(:permit!)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@permitted = true
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Ensure that a parameter is present and not empty.
|
|
106
|
+
#
|
|
107
|
+
# If the parameter is missing or empty, raises ParameterMissing exception.
|
|
108
|
+
# If the parameter is present and is a Parameters object, tracks the key
|
|
109
|
+
# for later use by transform_params.
|
|
110
|
+
#
|
|
111
|
+
# @param key [Symbol, String] the parameter key to require
|
|
112
|
+
# @return [Object] the parameter value
|
|
113
|
+
# @raise [ParameterMissing] if the parameter is missing or empty
|
|
114
|
+
# @example
|
|
115
|
+
# params.require(:user)
|
|
116
|
+
# params.require(:user).permit(:name, :email)
|
|
117
|
+
def require(key)
|
|
118
|
+
value = self[key].presence || raise(ActionController::ParameterMissing.new(key))
|
|
119
|
+
# Track the required key so transform_params can infer the params class
|
|
120
|
+
if value.is_a?(Parameters)
|
|
121
|
+
value.required_key = key
|
|
122
|
+
end
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
alias required require
|
|
127
|
+
|
|
128
|
+
# Create a new Parameters object with only the specified keys permitted.
|
|
129
|
+
#
|
|
130
|
+
# Filters can be symbols/strings for scalar values, or hashes for nested
|
|
131
|
+
# parameters. Only explicitly permitted parameters will be included in
|
|
132
|
+
# the returned object.
|
|
133
|
+
#
|
|
134
|
+
# @param filters [Array<Symbol, String, Hash>] the keys to permit
|
|
135
|
+
# @return [Parameters] a new Parameters object containing only permitted keys
|
|
136
|
+
# @example Permit scalar values
|
|
137
|
+
# params.permit(:name, :age)
|
|
138
|
+
# @example Permit nested parameters
|
|
139
|
+
# params.permit(:name, emails: [], friends: [:name, { family: [:name] }])
|
|
140
|
+
def permit(*filters)
|
|
141
|
+
params = self.class.new
|
|
142
|
+
|
|
143
|
+
filters.flatten.each do |filter|
|
|
144
|
+
case filter
|
|
145
|
+
when Symbol, String
|
|
146
|
+
permitted_scalar_filter(params, filter)
|
|
147
|
+
when Hash
|
|
148
|
+
hash_filter(params, filter)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
unpermitted_parameters!(params) if self.class.action_on_unpermitted_parameters
|
|
153
|
+
|
|
154
|
+
params.permit!
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Transform and permit parameters using a declarative params class.
|
|
158
|
+
#
|
|
159
|
+
# This method provides a declarative approach to parameter filtering using
|
|
160
|
+
# params classes defined in app/params/. It automatically looks up the
|
|
161
|
+
# appropriate params class based on the required_key if not explicitly provided.
|
|
162
|
+
#
|
|
163
|
+
# @param params_class [Class, Symbol, nil] optional params class or :__infer__ (default)
|
|
164
|
+
# - Class: Use this specific params class (e.g., UserParams)
|
|
165
|
+
# - :__infer__: Infer from required_key (e.g., :user -> UserParams)
|
|
166
|
+
# - nil: Skip lookup and return empty permitted params
|
|
167
|
+
# @param options [Hash] metadata and configuration options
|
|
168
|
+
# @option options [Symbol, String] :action Action name for action-specific filtering
|
|
169
|
+
# @option options [Array<Symbol>] :additional_attrs Additional attributes to permit
|
|
170
|
+
# @option options [Object] :current_user Current user (always allowed, no declaration needed)
|
|
171
|
+
# @return [Parameters] permitted parameters with only allowed attributes
|
|
172
|
+
# @raise [ArgumentError] if undeclared metadata keys are passed
|
|
173
|
+
#
|
|
174
|
+
# @example Basic usage with inference
|
|
175
|
+
# params.require(:user).transform_params
|
|
176
|
+
# # Looks up UserParams automatically
|
|
177
|
+
#
|
|
178
|
+
# @example Explicit params class
|
|
179
|
+
# params.require(:user).transform_params(AdminUserParams)
|
|
180
|
+
#
|
|
181
|
+
# @example With action-specific filtering
|
|
182
|
+
# params.require(:post).transform_params(action: :create)
|
|
183
|
+
#
|
|
184
|
+
# @example With additional attributes
|
|
185
|
+
# params.require(:user).transform_params(additional_attrs: [:temp_token])
|
|
186
|
+
#
|
|
187
|
+
# @example With metadata
|
|
188
|
+
# params.require(:account).transform_params(
|
|
189
|
+
# current_user: current_user,
|
|
190
|
+
# ip_address: request.ip,
|
|
191
|
+
# role: current_user.role
|
|
192
|
+
# )
|
|
193
|
+
# # Note: :ip_address and :role must be declared in AccountParams using `metadata :ip_address, :role`
|
|
194
|
+
#
|
|
195
|
+
# @note Any metadata keys other than :current_user must be explicitly declared
|
|
196
|
+
# in the params class using the `metadata` DSL method.
|
|
197
|
+
def transform_params(params_class = :__infer__, **options)
|
|
198
|
+
# Extract known options (these don't need to be declared as metadata)
|
|
199
|
+
action = options[:action]
|
|
200
|
+
additional_attrs = options[:additional_attrs] || []
|
|
201
|
+
|
|
202
|
+
# Infer params class from required_key if not explicitly provided
|
|
203
|
+
if params_class == :__infer__
|
|
204
|
+
if @required_key
|
|
205
|
+
params_class = ParamsRegistry.lookup(@required_key)
|
|
206
|
+
else
|
|
207
|
+
# If no required_key and no explicit params_class, return empty permitted params
|
|
208
|
+
return self.class.new.permit!
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Handle case where params_class is nil (explicitly passed or not registered)
|
|
213
|
+
if params_class.nil?
|
|
214
|
+
return self.class.new.permit!
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Validate metadata keys (excluding known options)
|
|
218
|
+
metadata_keys = options.keys - [:action, :additional_attrs]
|
|
219
|
+
validate_metadata_keys!(params_class, metadata_keys)
|
|
220
|
+
|
|
221
|
+
# Get permitted attributes and apply them
|
|
222
|
+
permitted_attrs = params_class.permitted_attributes(action: action)
|
|
223
|
+
permitted_attrs += additional_attrs
|
|
224
|
+
permit(*permitted_attrs)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Legacy alias for backwards compatibility.
|
|
228
|
+
#
|
|
229
|
+
# @deprecated Use {#transform_params} instead
|
|
230
|
+
# @param model_name [Symbol, String] the model name to look up
|
|
231
|
+
# @param action [Symbol, String, nil] optional action name
|
|
232
|
+
# @param additional_attrs [Array<Symbol>] additional attributes to permit
|
|
233
|
+
# @return [Parameters] permitted parameters
|
|
234
|
+
def permit_by_model(model_name, action: nil, additional_attrs: [])
|
|
235
|
+
params_class = ParamsRegistry.lookup(model_name)
|
|
236
|
+
transform_params(params_class, action: action, additional_attrs: additional_attrs)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Access a parameter value by key, converting hashes to Parameters objects.
|
|
240
|
+
#
|
|
241
|
+
# @param key [Symbol, String] the parameter key
|
|
242
|
+
# @return [Object] the parameter value
|
|
243
|
+
def [](key)
|
|
244
|
+
convert_hashes_to_parameters(key, super)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Fetch a parameter value by key, raising ParameterMissing if not found.
|
|
248
|
+
#
|
|
249
|
+
# @param key [Symbol, String] the parameter key
|
|
250
|
+
# @param args additional arguments passed to Hash#fetch
|
|
251
|
+
# @return [Object] the parameter value
|
|
252
|
+
# @raise [ParameterMissing] if the key is not found
|
|
253
|
+
def fetch(key, *args)
|
|
254
|
+
convert_hashes_to_parameters(key, super, false)
|
|
255
|
+
rescue KeyError, IndexError
|
|
256
|
+
raise ActionController::ParameterMissing.new(key)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Create a new Parameters object containing only the specified keys.
|
|
260
|
+
#
|
|
261
|
+
# @param keys [Array<Symbol, String>] the keys to include
|
|
262
|
+
# @return [Parameters] a new Parameters object with only the specified keys
|
|
263
|
+
# @example
|
|
264
|
+
# params.slice(:name, :email)
|
|
265
|
+
def slice(*keys)
|
|
266
|
+
# Manually slice the hash since ActiveSupport 3.0 might not have slice
|
|
267
|
+
sliced = {}
|
|
268
|
+
keys.each do |key|
|
|
269
|
+
sliced[key] = self[key] if has_key?(key)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
self.class.new(sliced).tap do |new_instance|
|
|
273
|
+
new_instance.instance_variable_set(:@permitted, @permitted)
|
|
274
|
+
new_instance.instance_variable_set(:@required_key, @required_key)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Create a new Parameters object excluding the specified keys.
|
|
279
|
+
#
|
|
280
|
+
# @param keys [Array<Symbol, String>] the keys to exclude
|
|
281
|
+
# @return [Parameters] a new Parameters object without the specified keys
|
|
282
|
+
# @example
|
|
283
|
+
# params.except(:password, :password_confirmation)
|
|
284
|
+
def except(*keys)
|
|
285
|
+
# Return a new Parameters instance excluding the specified keys
|
|
286
|
+
excepted = dup
|
|
287
|
+
keys.each { |key| excepted.delete(key) }
|
|
288
|
+
excepted
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Create a duplicate of this Parameters object.
|
|
292
|
+
#
|
|
293
|
+
# @return [Parameters] a new Parameters object with the same data and state
|
|
294
|
+
def dup
|
|
295
|
+
self.class.new(self).tap do |duplicate|
|
|
296
|
+
duplicate.default = default
|
|
297
|
+
duplicate.instance_variable_set(:@permitted, @permitted)
|
|
298
|
+
duplicate.instance_variable_set(:@required_key, @required_key)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
protected
|
|
303
|
+
def convert_value(value)
|
|
304
|
+
if value.class == Hash
|
|
305
|
+
self.class.new_from_hash_copying_default(value)
|
|
306
|
+
elsif value.is_a?(Array)
|
|
307
|
+
value.dup.replace(value.map { |e| convert_value(e) })
|
|
308
|
+
else
|
|
309
|
+
value
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
def convert_hashes_to_parameters(key, value, assign_if_converted=true)
|
|
316
|
+
converted = convert_value_to_parameters(value)
|
|
317
|
+
self[key] = converted if assign_if_converted && !converted.equal?(value)
|
|
318
|
+
converted
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def convert_value_to_parameters(value)
|
|
322
|
+
if value.is_a?(Array)
|
|
323
|
+
value.map { |_| convert_value_to_parameters(_) }
|
|
324
|
+
elsif value.is_a?(Parameters) || !value.is_a?(Hash)
|
|
325
|
+
value
|
|
326
|
+
else
|
|
327
|
+
self.class.new(value)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
#
|
|
332
|
+
# --- Filtering ----------------------------------------------------------
|
|
333
|
+
#
|
|
334
|
+
|
|
335
|
+
# Whitelist of permitted scalar types for parameter filtering.
|
|
336
|
+
#
|
|
337
|
+
# These types are considered safe for mass assignment and include types
|
|
338
|
+
# commonly used in XML and JSON requests. String is first to optimize
|
|
339
|
+
# the common case through short-circuit evaluation.
|
|
340
|
+
#
|
|
341
|
+
# Note: DateTime inherits from Date, so it's implicitly included via Date.
|
|
342
|
+
#
|
|
343
|
+
# @note If you modify this list, please update the README documentation.
|
|
344
|
+
PERMITTED_SCALAR_TYPES = [
|
|
345
|
+
String,
|
|
346
|
+
Symbol,
|
|
347
|
+
NilClass,
|
|
348
|
+
Numeric,
|
|
349
|
+
TrueClass,
|
|
350
|
+
FalseClass,
|
|
351
|
+
Date,
|
|
352
|
+
Time,
|
|
353
|
+
# DateTimes are Dates, we document the type but avoid the redundant check.
|
|
354
|
+
StringIO,
|
|
355
|
+
IO,
|
|
356
|
+
ActionDispatch::Http::UploadedFile,
|
|
357
|
+
Rack::Test::UploadedFile
|
|
358
|
+
].freeze
|
|
359
|
+
|
|
360
|
+
# Check if a value is a permitted scalar type.
|
|
361
|
+
#
|
|
362
|
+
# @param value [Object] the value to check
|
|
363
|
+
# @return [Boolean] true if value is a permitted scalar type
|
|
364
|
+
def permitted_scalar?(value)
|
|
365
|
+
PERMITTED_SCALAR_TYPES.any? { |type| value.is_a?(type) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Check if a value is an array containing only permitted scalars.
|
|
369
|
+
#
|
|
370
|
+
# @param value [Object] the value to check
|
|
371
|
+
# @return [Boolean, nil] true if array of permitted scalars, nil otherwise
|
|
372
|
+
def array_of_permitted_scalars?(value)
|
|
373
|
+
return unless value.is_a?(Array)
|
|
374
|
+
|
|
375
|
+
value.all? { |element| permitted_scalar?(element) }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def permitted_scalar_filter(params, key)
|
|
379
|
+
if has_key?(key) && permitted_scalar?(self[key])
|
|
380
|
+
params[key] = self[key]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
keys.grep(/\A#{Regexp.escape(key.to_s)}\(\d+[if]?\)\z/).each do |key|
|
|
384
|
+
if permitted_scalar?(self[key])
|
|
385
|
+
params[key] = self[key]
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def array_of_permitted_scalars_filter(params, key, hash = self)
|
|
391
|
+
if hash.has_key?(key) && array_of_permitted_scalars?(hash[key])
|
|
392
|
+
params[key] = hash[key]
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def hash_filter(params, filter)
|
|
397
|
+
filter = filter.with_indifferent_access
|
|
398
|
+
|
|
399
|
+
# Slicing filters out non-declared keys.
|
|
400
|
+
slice(*filter.keys).each do |key, value|
|
|
401
|
+
next unless value
|
|
402
|
+
|
|
403
|
+
if filter[key] == []
|
|
404
|
+
# Declaration {:comment_ids => []}.
|
|
405
|
+
array_of_permitted_scalars_filter(params, key)
|
|
406
|
+
else
|
|
407
|
+
# Declaration {:user => :name} or {:user => [:name, :age, {:adress => ...}]}.
|
|
408
|
+
params[key] = each_element(value) do |element, index|
|
|
409
|
+
if element.is_a?(Hash)
|
|
410
|
+
element = self.class.new(element) unless element.respond_to?(:permit)
|
|
411
|
+
element.permit(*Array.wrap(filter[key]))
|
|
412
|
+
elsif filter[key].is_a?(Hash) && filter[key][index] == []
|
|
413
|
+
array_of_permitted_scalars_filter(params, index, value)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def each_element(value)
|
|
421
|
+
if value.is_a?(Array)
|
|
422
|
+
value.map { |el| yield el }.compact
|
|
423
|
+
# fields_for on an array of records uses numeric hash keys.
|
|
424
|
+
elsif fields_for_style?(value)
|
|
425
|
+
hash = value.class.new
|
|
426
|
+
value.each { |k,v| hash[k] = yield(v, k) }
|
|
427
|
+
hash
|
|
428
|
+
else
|
|
429
|
+
yield value
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def fields_for_style?(object)
|
|
434
|
+
object.is_a?(Hash) && object.all? { |k, v| k =~ /\A-?\d+\z/ && v.is_a?(Hash) }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def unpermitted_parameters!(params)
|
|
438
|
+
return unless self.class.action_on_unpermitted_parameters
|
|
439
|
+
|
|
440
|
+
unpermitted_keys = unpermitted_keys(params)
|
|
441
|
+
|
|
442
|
+
if unpermitted_keys.any?
|
|
443
|
+
case self.class.action_on_unpermitted_parameters
|
|
444
|
+
when :log
|
|
445
|
+
name = "unpermitted_parameters.action_controller"
|
|
446
|
+
ActiveSupport::Notifications.instrument(name, :keys => unpermitted_keys)
|
|
447
|
+
when :raise
|
|
448
|
+
raise ActionController::UnpermittedParameters.new(unpermitted_keys)
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def unpermitted_keys(params)
|
|
454
|
+
self.keys - params.keys - NEVER_UNPERMITTED_PARAMS
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Validate that all provided metadata keys are allowed by the params class.
|
|
458
|
+
#
|
|
459
|
+
# @param params_class [Class] the params class to validate against
|
|
460
|
+
# @param metadata_keys [Array<Symbol>] the metadata keys to validate
|
|
461
|
+
# @raise [ArgumentError] if any metadata keys are not declared
|
|
462
|
+
# @return [void]
|
|
463
|
+
def validate_metadata_keys!(params_class, metadata_keys)
|
|
464
|
+
return if metadata_keys.empty?
|
|
465
|
+
|
|
466
|
+
disallowed_keys = metadata_keys.reject { |key| params_class.metadata_allowed?(key) }
|
|
467
|
+
|
|
468
|
+
return unless disallowed_keys.any?
|
|
469
|
+
|
|
470
|
+
# Build a helpful error message
|
|
471
|
+
keys_list = disallowed_keys.map(&:inspect).join(', ')
|
|
472
|
+
class_name = params_class.name
|
|
473
|
+
|
|
474
|
+
raise ArgumentError, <<~ERROR.strip
|
|
475
|
+
Metadata key(s) #{keys_list} not allowed for #{class_name}.
|
|
476
|
+
|
|
477
|
+
To fix this, declare them in your params class:
|
|
478
|
+
|
|
479
|
+
class #{class_name} < ApplicationParams
|
|
480
|
+
metadata #{disallowed_keys.map(&:inspect).join(', ')}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
Note: :current_user is always implicitly allowed and doesn't need to be declared.
|
|
484
|
+
ERROR
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Controller integration module for Strong Parameters.
|
|
489
|
+
#
|
|
490
|
+
# This module provides the params method to controllers and handles
|
|
491
|
+
# ParameterMissing exceptions with a 400 Bad Request response.
|
|
492
|
+
#
|
|
493
|
+
# @example
|
|
494
|
+
# class ApplicationController < ActionController::Base
|
|
495
|
+
# include ActionController::StrongParameters
|
|
496
|
+
# end
|
|
497
|
+
module StrongParameters
|
|
498
|
+
extend ActiveSupport::Concern
|
|
499
|
+
|
|
500
|
+
included do
|
|
501
|
+
rescue_from(ActionController::ParameterMissing) do |parameter_missing_exception|
|
|
502
|
+
render text: "Required parameter missing: #{parameter_missing_exception.param}",
|
|
503
|
+
status: :bad_request
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Access request parameters as a Parameters object.
|
|
508
|
+
#
|
|
509
|
+
# @return [Parameters] the request parameters wrapped in a Parameters object
|
|
510
|
+
def params
|
|
511
|
+
@_params ||= Parameters.new(request.parameters)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Set the parameters for this request.
|
|
515
|
+
#
|
|
516
|
+
# @param val [Hash, Parameters] the parameters to set
|
|
517
|
+
# @return [Parameters] the parameters
|
|
518
|
+
def params=(val)
|
|
519
|
+
@_params = val.is_a?(Hash) ? Parameters.new(val) : val
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
ActiveSupport.on_load(:action_controller) { include ActionController::StrongParameters }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionController
|
|
4
|
+
# Singleton registry for storing and retrieving param class definitions.
|
|
5
|
+
#
|
|
6
|
+
# ParamsRegistry provides a central location to register and look up params
|
|
7
|
+
# classes for models. This enables automatic inference of params classes in
|
|
8
|
+
# transform_params based on the model name.
|
|
9
|
+
#
|
|
10
|
+
# @example Registering a params class
|
|
11
|
+
# ParamsRegistry.register(:user, UserParams)
|
|
12
|
+
#
|
|
13
|
+
# @example Looking up a params class
|
|
14
|
+
# ParamsRegistry.lookup(:user) # => UserParams
|
|
15
|
+
#
|
|
16
|
+
# @example Getting permitted attributes
|
|
17
|
+
# ParamsRegistry.permitted_attributes_for(:user, action: :create)
|
|
18
|
+
class ParamsRegistry
|
|
19
|
+
class << self
|
|
20
|
+
# Register a params class for a model.
|
|
21
|
+
#
|
|
22
|
+
# The model name is normalized (underscored and symbolized) before storage.
|
|
23
|
+
#
|
|
24
|
+
# @param model_name [String, Symbol] the model name (e.g., 'User', 'Account')
|
|
25
|
+
# @param params_class [Class] the params class (e.g., UserParams)
|
|
26
|
+
# @return [Class] the registered params class
|
|
27
|
+
# @example
|
|
28
|
+
# ParamsRegistry.register(:user, UserParams)
|
|
29
|
+
# ParamsRegistry.register('BlogPost', BlogPostParams)
|
|
30
|
+
def register(model_name, params_class)
|
|
31
|
+
registry[normalize_key(model_name)] = params_class
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Look up the params class for a model.
|
|
35
|
+
#
|
|
36
|
+
# @param model_name [String, Symbol] the model name
|
|
37
|
+
# @return [Class, nil] the params class or nil if not found
|
|
38
|
+
# @example
|
|
39
|
+
# ParamsRegistry.lookup(:user) # => UserParams
|
|
40
|
+
# ParamsRegistry.lookup(:unknown) # => nil
|
|
41
|
+
def lookup(model_name)
|
|
42
|
+
registry[normalize_key(model_name)]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get permitted attributes for a model.
|
|
46
|
+
#
|
|
47
|
+
# @param model_name [String, Symbol] the model name
|
|
48
|
+
# @param action [Symbol, String, nil] optional action name for filtering
|
|
49
|
+
# @return [Array<Symbol, Hash>] array of permitted attributes
|
|
50
|
+
# @example
|
|
51
|
+
# ParamsRegistry.permitted_attributes_for(:user)
|
|
52
|
+
# ParamsRegistry.permitted_attributes_for(:post, action: :create)
|
|
53
|
+
def permitted_attributes_for(model_name, action: nil)
|
|
54
|
+
params_class = lookup(model_name)
|
|
55
|
+
return [] unless params_class
|
|
56
|
+
|
|
57
|
+
params_class.permitted_attributes(action: action)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if a model has registered params.
|
|
61
|
+
#
|
|
62
|
+
# @param model_name [String, Symbol] the model name
|
|
63
|
+
# @return [Boolean] true if registered, false otherwise
|
|
64
|
+
# @example
|
|
65
|
+
# ParamsRegistry.registered?(:user) # => true
|
|
66
|
+
# ParamsRegistry.registered?(:unknown) # => false
|
|
67
|
+
def registered?(model_name)
|
|
68
|
+
registry.key?(normalize_key(model_name))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Clear the registry.
|
|
72
|
+
#
|
|
73
|
+
# This is primarily useful for testing to ensure a clean slate between tests.
|
|
74
|
+
#
|
|
75
|
+
# @return [Hash] the empty registry
|
|
76
|
+
def clear!
|
|
77
|
+
registry.clear
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get all registered model names.
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<String>] array of model names as strings
|
|
83
|
+
# @example
|
|
84
|
+
# ParamsRegistry.registered_models # => ["user", "post", "comment"]
|
|
85
|
+
def registered_models
|
|
86
|
+
registry.keys.map(&:to_s)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# @return [Hash<Symbol, Class>] the internal registry hash
|
|
92
|
+
def registry
|
|
93
|
+
@registry ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Normalize a key by underscoring and symbolizing it.
|
|
97
|
+
#
|
|
98
|
+
# @param key [String, Symbol] the key to normalize
|
|
99
|
+
# @return [Symbol] the normalized key
|
|
100
|
+
# @example
|
|
101
|
+
# normalize_key('BlogPost') # => :blog_post
|
|
102
|
+
# normalize_key(:user) # => :user
|
|
103
|
+
def normalize_key(key)
|
|
104
|
+
key.to_s.underscore.to_sym
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveModel
|
|
4
|
+
# Exception raised when attempting mass assignment with unpermitted parameters.
|
|
5
|
+
#
|
|
6
|
+
# This exception is raised when ActionController::Parameters are used in
|
|
7
|
+
# mass assignment without being explicitly permitted using permit() or permit!().
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# User.new(params[:user])
|
|
11
|
+
# # => ActiveModel::ForbiddenAttributes (if :user params not permitted)
|
|
12
|
+
class ForbiddenAttributes < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Protection module for Active Model mass assignment.
|
|
16
|
+
#
|
|
17
|
+
# This module overrides sanitize_for_mass_assignment to check that
|
|
18
|
+
# ActionController::Parameters objects are marked as permitted before
|
|
19
|
+
# allowing mass assignment.
|
|
20
|
+
#
|
|
21
|
+
# @example Include in a model
|
|
22
|
+
# class Post < ActiveRecord::Base
|
|
23
|
+
# include ActiveModel::ForbiddenAttributesProtection
|
|
24
|
+
# end
|
|
25
|
+
module ForbiddenAttributesProtection
|
|
26
|
+
# Check if parameters are permitted before mass assignment.
|
|
27
|
+
#
|
|
28
|
+
# @param options [Array] mass assignment options, first element should be attributes hash
|
|
29
|
+
# @return [Object] result of super if permitted
|
|
30
|
+
# @raise [ForbiddenAttributes] if parameters are not permitted
|
|
31
|
+
def sanitize_for_mass_assignment(*options)
|
|
32
|
+
new_attributes = options.first
|
|
33
|
+
if !new_attributes.respond_to?(:permitted?) || new_attributes.permitted?
|
|
34
|
+
super
|
|
35
|
+
else
|
|
36
|
+
raise ActiveModel::ForbiddenAttributes
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require 'test_helper'
|
|
2
|
+
|
|
3
|
+
class BooksController < ActionController::Base
|
|
4
|
+
def create
|
|
5
|
+
params.require(:book).require(:name)
|
|
6
|
+
head :ok
|
|
7
|
+
rescue ActionController::ParameterMissing => e
|
|
8
|
+
render plain: e.message, status: :bad_request
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class ActionControllerRequiredParamsTest < ActionController::TestCase
|
|
13
|
+
tests BooksController
|
|
14
|
+
|
|
15
|
+
setup do
|
|
16
|
+
@routes = Rails.application.routes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_missing_required_parameters_will_raise_exception
|
|
20
|
+
post :create, params: { :magazine => { :name => "Mjallo!" } }
|
|
21
|
+
assert_response :bad_request
|
|
22
|
+
|
|
23
|
+
post :create, params: { :book => { :title => "Mjallo!" } }
|
|
24
|
+
assert_response :bad_request
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_required_parameters_that_are_present_will_not_raise
|
|
28
|
+
post :create, params: { :book => { :name => "Mjallo!" } }
|
|
29
|
+
assert_response :ok
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_missing_parameters_will_be_mentioned_in_the_response
|
|
33
|
+
post :create, params: { :magazine => { :name => "Mjallo!" } }
|
|
34
|
+
assert_includes response.body, "book"
|
|
35
|
+
end
|
|
36
|
+
end
|