feat(security): add Prism and StrongParameters
This commit is contained in:
1 parent
590cd639aa
commit
bf0042120f
1 file changed
+143
@@ -0,0 +1,143 @@
|
||||
module Prism
|
||||
# Rails-style allowlisting for request parameters: nothing reaches a
|
||||
# model's mass-assignment until it has been explicitly required and
|
||||
# permitted, so an attacker cannot smuggle extra columns (e.g. `admin`)
|
||||
# into a create/update call just by adding fields to a form.
|
||||
class StrongParameters
|
||||
class ParameterMissing < StandardError
|
||||
def initialize(key)
|
||||
super("param is missing or the value is empty: #{key}")
|
||||
end
|
||||
end
|
||||
|
||||
class UnfilteredParameters < StandardError; end
|
||||
|
||||
def initialize(hash, permitted = false)
|
||||
@hash = hash.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
|
||||
@permitted = permitted
|
||||
end
|
||||
|
||||
def permitted?
|
||||
@permitted
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@hash[key.to_s]
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
@hash.key?(key.to_s)
|
||||
end
|
||||
alias_method :has_key?, :key?
|
||||
|
||||
def require(key)
|
||||
value = self[key]
|
||||
raise ParameterMissing, key if blank?(value)
|
||||
value.is_a?(Hash) ? self.class.new(value) : value
|
||||
end
|
||||
|
||||
def permit(*filters)
|
||||
result = {}
|
||||
filters.each do |filter|
|
||||
case filter
|
||||
when Symbol, String then permit_scalar(filter, result)
|
||||
when Hash then permit_nested(filter, result)
|
||||
else raise ArgumentError, "unsupported permit filter #{filter.inspect}"
|
||||
end
|
||||
end
|
||||
self.class.new(result, true)
|
||||
end
|
||||
|
||||
def to_h
|
||||
raise UnfilteredParameters, 'unable to convert unpermitted parameters to hash' unless permitted?
|
||||
to_unsafe_h
|
||||
end
|
||||
|
||||
def to_unsafe_h
|
||||
deep_copy(@hash)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blank?(value)
|
||||
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
||||
end
|
||||
|
||||
def permit_scalar(filter, result)
|
||||
key = filter.to_s
|
||||
return unless @hash.key?(key)
|
||||
value = @hash[key]
|
||||
result[key] = value unless value.is_a?(Hash) || value.is_a?(Array)
|
||||
end
|
||||
|
||||
def permit_nested(filter, result)
|
||||
filter.each do |raw_key, nested_filters|
|
||||
key = raw_key.to_s
|
||||
next unless @hash.key?(key)
|
||||
value = @hash[key]
|
||||
next unless value.is_a?(Hash) || value.is_a?(Array)
|
||||
result[key] = permit_nested_value(value, Array(nested_filters))
|
||||
end
|
||||
end
|
||||
|
||||
def permit_nested_value(value, nested_filters)
|
||||
if value.is_a?(Hash)
|
||||
indexed_hash?(value) ? permit_indexed_hash(value, nested_filters)
|
||||
: self.class.new(value).permit(*nested_filters).to_unsafe_h
|
||||
else
|
||||
value.filter_map { |item| permit_array_item(item, nested_filters) }
|
||||
end
|
||||
end
|
||||
|
||||
def permit_array_item(item, nested_filters)
|
||||
if item.is_a?(Hash)
|
||||
nested_filters.empty? ? nil : self.class.new(item).permit(*nested_filters).to_unsafe_h
|
||||
else
|
||||
nested_filters.empty? ? item : nil
|
||||
end
|
||||
end
|
||||
|
||||
# A browser form built for `accepts_nested_attributes_for` (via
|
||||
# Prism::FormBuilder-style "object[assoc_attributes][0][field]" names)
|
||||
# arrives, once Prism::Params.nested_from expands the brackets, as a
|
||||
# Hash keyed by the string indices "0", "1", ... - NOT an Array. Left
|
||||
# to the plain nested-Hash branch above, `permit` would try to permit
|
||||
# "0"/"1" themselves as attribute names against the caller's filter
|
||||
# list (:id, :body, ...) and silently drop everything, so
|
||||
# `comments_attributes` would always end up `{}` even though the
|
||||
# request genuinely carried two comments - exactly the sort of bug that
|
||||
# only shows up over a real request cycle, not in a unit test built
|
||||
# directly on a Ruby Array. Detecting the all-numeric-key shape lets
|
||||
# each entry go through the same per-item allowlist as a real Array,
|
||||
# while keeping the original index keys in the result (matching what
|
||||
# Model#assign / accepts_nested_attributes_for expects to iterate).
|
||||
def indexed_hash?(value)
|
||||
!value.empty? && value.keys.all? { |key| key.to_s.match?(/\A\d+\z/) }
|
||||
end
|
||||
|
||||
def permit_indexed_hash(value, nested_filters)
|
||||
value.each_with_object({}) do |(index, item), acc|
|
||||
permitted = permit_array_item(item, nested_filters)
|
||||
acc[index] = permitted unless permitted.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def deep_copy(value)
|
||||
case value
|
||||
when Hash then value.each_with_object({}) { |(key, val), acc| acc[key] = deep_copy(val) }
|
||||
when Array then value.map { |item| deep_copy(item) }
|
||||
else value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class Params
|
||||
def require(key)
|
||||
StrongParameters.new(self).require(key)
|
||||
end
|
||||
|
||||
def permit(*filters)
|
||||
StrongParameters.new(self).permit(*filters)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user