Permalink
Browse files

Mass assignment security refactoring

Signed-off-by: José Valim <jose.valim@gmail.com>
  • Loading branch information...
1 parent 723a0bb commit 606088df3f10dd8daec8ccc97d8279c119a503b5 @eac eac committed with josevalim Jan 30, 2010
@@ -64,6 +64,7 @@ module ActiveRecord
autoload :CounterCache
autoload :DynamicFinderMatch
autoload :DynamicScopeMatch
+ autoload :MassAssignmentSecurity
autoload :Migration
autoload :Migrator, 'active_record/migration'
autoload :NamedScope
@@ -24,7 +24,7 @@
require 'active_record/log_subscriber'
module ActiveRecord #:nodoc:
- # = Active Record
+ # = Active Record
#
# Active Record objects don't specify their attributes directly, but rather infer them from the table definition with
# which they're linked. Adding, removing, and changing attributes and their type is done directly in the database. Any change
@@ -476,112 +476,16 @@ def count_by_sql(sql)
connection.select_value(sql, "#{name} Count").to_i
end
- # Attributes named in this macro are protected from mass-assignment,
- # such as <tt>new(attributes)</tt>,
- # <tt>update_attributes(attributes)</tt>, or
- # <tt>attributes=(attributes)</tt>.
- #
- # Mass-assignment to these attributes will simply be ignored, to assign
- # to them you can use direct writer methods. This is meant to protect
- # sensitive attributes from being overwritten by malicious users
- # tampering with URLs or forms.
- #
- # class Customer < ActiveRecord::Base
- # attr_protected :credit_rating
- # end
- #
- # customer = Customer.new("name" => David, "credit_rating" => "Excellent")
- # customer.credit_rating # => nil
- # customer.attributes = { "description" => "Jolly fellow", "credit_rating" => "Superb" }
- # customer.credit_rating # => nil
- #
- # customer.credit_rating = "Average"
- # customer.credit_rating # => "Average"
- #
- # To start from an all-closed default and enable attributes as needed,
- # have a look at +attr_accessible+.
- #
- # If the access logic of your application is richer you can use <tt>Hash#except</tt>
- # or <tt>Hash#slice</tt> to sanitize the hash of parameters before they are
- # passed to Active Record.
- #
- # For example, it could be the case that the list of protected attributes
- # for a given model depends on the role of the user:
- #
- # # Assumes plan_id is not protected because it depends on the role.
- # params[:account] = params[:account].except(:plan_id) unless admin?
- # @account.update_attributes(params[:account])
- #
- # Note that +attr_protected+ is still applied to the received hash. Thus,
- # with this technique you can at most _extend_ the list of protected
- # attributes for a particular mass-assignment call.
- def attr_protected(*attributes)
- write_inheritable_attribute(:attr_protected, Set.new(attributes.map {|a| a.to_s}) + (protected_attributes || []))
- end
-
- # Returns an array of all the attributes that have been protected from mass-assignment.
- def protected_attributes # :nodoc:
- read_inheritable_attribute(:attr_protected)
- end
-
- # Specifies a white list of model attributes that can be set via
- # mass-assignment, such as <tt>new(attributes)</tt>,
- # <tt>update_attributes(attributes)</tt>, or
- # <tt>attributes=(attributes)</tt>
- #
- # This is the opposite of the +attr_protected+ macro: Mass-assignment
- # will only set attributes in this list, to assign to the rest of
- # attributes you can use direct writer methods. This is meant to protect
- # sensitive attributes from being overwritten by malicious users
- # tampering with URLs or forms. If you'd rather start from an all-open
- # default and restrict attributes as needed, have a look at
- # +attr_protected+.
- #
- # class Customer < ActiveRecord::Base
- # attr_accessible :name, :nickname
- # end
- #
- # customer = Customer.new(:name => "David", :nickname => "Dave", :credit_rating => "Excellent")
- # customer.credit_rating # => nil
- # customer.attributes = { :name => "Jolly fellow", :credit_rating => "Superb" }
- # customer.credit_rating # => nil
- #
- # customer.credit_rating = "Average"
- # customer.credit_rating # => "Average"
- #
- # If the access logic of your application is richer you can use <tt>Hash#except</tt>
- # or <tt>Hash#slice</tt> to sanitize the hash of parameters before they are
- # passed to Active Record.
- #
- # For example, it could be the case that the list of accessible attributes
- # for a given model depends on the role of the user:
- #
- # # Assumes plan_id is accessible because it depends on the role.
- # params[:account] = params[:account].except(:plan_id) unless admin?
- # @account.update_attributes(params[:account])
- #
- # Note that +attr_accessible+ is still applied to the received hash. Thus,
- # with this technique you can at most _narrow_ the list of accessible
- # attributes for a particular mass-assignment call.
- def attr_accessible(*attributes)
- write_inheritable_attribute(:attr_accessible, Set.new(attributes.map(&:to_s)) + (accessible_attributes || []))
+ # Attributes listed as readonly can be set for a new record, but will be ignored in database updates afterwards.
+ def attr_readonly(*attributes)
+ write_inheritable_attribute(:attr_readonly, Set.new(attributes.map(&:to_s)) + (readonly_attributes || []))
end
- # Returns an array of all the attributes that have been made accessible to mass-assignment.
- def accessible_attributes # :nodoc:
- read_inheritable_attribute(:attr_accessible)
+ # Returns an array of all the attributes that have been specified as readonly.
+ def readonly_attributes
+ read_inheritable_attribute(:attr_readonly) || []
end
- # Attributes listed as readonly can be set for a new record, but will be ignored in database updates afterwards.
- def attr_readonly(*attributes)
- write_inheritable_attribute(:attr_readonly, Set.new(attributes.map(&:to_s)) + (readonly_attributes || []))
- end
-
- # Returns an array of all the attributes that have been specified as readonly.
- def readonly_attributes
- read_inheritable_attribute(:attr_readonly) || []
- end
-
# If you have an attribute that needs to be saved to the database as an object, and retrieved as the same object,
# then specify the name of that attribute using this method and it will be handled automatically.
# The serialization is done through YAML. If +class_name+ is specified, the serialized object must be of that
@@ -1716,27 +1620,6 @@ def ensure_proper_type
end
end
- def remove_attributes_protected_from_mass_assignment(attributes)
- safe_attributes =
- if self.class.accessible_attributes.nil? && self.class.protected_attributes.nil?
- attributes.reject { |key, value| attributes_protected_by_default.include?(key.gsub(/\(.+/, "")) }
- elsif self.class.protected_attributes.nil?
- attributes.reject { |key, value| !self.class.accessible_attributes.include?(key.gsub(/\(.+/, "")) || attributes_protected_by_default.include?(key.gsub(/\(.+/, "")) }
- elsif self.class.accessible_attributes.nil?
- attributes.reject { |key, value| self.class.protected_attributes.include?(key.gsub(/\(.+/,"")) || attributes_protected_by_default.include?(key.gsub(/\(.+/, "")) }
- else
- raise "Declare either attr_protected or attr_accessible for #{self.class}, but not both."
- end
-
- removed_attributes = attributes.keys - safe_attributes.keys
-
- if removed_attributes.any?
- log_protected_attribute_removal(removed_attributes)
- end
-
- safe_attributes
- end
-
# Removes attributes which have been marked as readonly.
def remove_readonly_attributes(attributes)
unless self.class.readonly_attributes.nil?
@@ -1746,16 +1629,10 @@ def remove_readonly_attributes(attributes)
end
end
- def log_protected_attribute_removal(*attributes)
- if logger
- logger.debug "WARNING: Can't mass-assign these protected attributes: #{attributes.join(', ')}"
- end
- end
-
# The primary key and inheritance column can never be set by mass-assignment for security reasons.
- def attributes_protected_by_default
- default = [ self.class.primary_key, self.class.inheritance_column ]
- default << 'id' unless self.class.primary_key.eql? 'id'
+ def self.attributes_protected_by_default
+ default = [ primary_key, inheritance_column ]
+ default << 'id' unless primary_key.eql? 'id'
default
end
@@ -1920,6 +1797,7 @@ def object_from_yaml(string)
include AttributeMethods::PrimaryKey
include AttributeMethods::TimeZoneConversion
include AttributeMethods::Dirty
+ extend MassAssignmentSecurity
include Callbacks, ActiveModel::Observing, Timestamp
include Associations, AssociationPreload, NamedScope
@@ -0,0 +1,160 @@
+require 'active_record/mass_assignment_security/permission_set'
+
+module ActiveRecord
+ module MassAssignmentSecurity
+ # Mass assignment security provides an interface for protecting attributes
+ # from end-user assignment. For more complex permissions, mass assignment security
+ # may be handled outside the model by extending a non-ActiveRecord class,
+ # such as a controller, with this behavior.
+ #
+ # For example, a logged in user may need to assign additional attributes depending
+ # on their role:
+ #
+ # class AccountsController < ApplicationController
+ # extend ActiveRecord::MassAssignmentSecurity
+ #
+ # attr_accessible :first_name, :last_name
+ #
+ # def self.admin_accessible_attributes
+ # accessible_attributes + [ :plan_id ]
+ # end
+ #
+ # def update
+ # ...
+ # @account.update_attributes(account_params)
+ # ...
+ # end
+ #
+ # protected
+ #
+ # def account_params
+ # remove_attributes_protected_from_mass_assignment(params[:account])
+ # end
+ #
+ # def mass_assignment_authorizer
+ # admin ? admin_accessible_attributes : super
+ # end
+ #
+ # end
+ #
+ def self.extended(base)
+ base.send(:include, InstanceMethods)
+ end
+
+ module InstanceMethods
+
+ protected
+
+ def remove_attributes_protected_from_mass_assignment(attributes)
+ mass_assignment_authorizer.sanitize(attributes)
+ end
+
+ def mass_assignment_authorizer
+ self.class.mass_assignment_authorizer
+ end
+
+ end
+
+ # Attributes named in this macro are protected from mass-assignment,
+ # such as <tt>new(attributes)</tt>,
+ # <tt>update_attributes(attributes)</tt>, or
+ # <tt>attributes=(attributes)</tt>.
+ #
+ # Mass-assignment to these attributes will simply be ignored, to assign
+ # to them you can use direct writer methods. This is meant to protect
+ # sensitive attributes from being overwritten by malicious users
+ # tampering with URLs or forms.
+ #
+ # class Customer < ActiveRecord::Base
+ # attr_protected :credit_rating
+ # end
+ #
+ # customer = Customer.new("name" => David, "credit_rating" => "Excellent")
+ # customer.credit_rating # => nil
+ # customer.attributes = { "description" => "Jolly fellow", "credit_rating" => "Superb" }
+ # customer.credit_rating # => nil
+ #
+ # customer.credit_rating = "Average"
+ # customer.credit_rating # => "Average"
+ #
+ # To start from an all-closed default and enable attributes as needed,
+ # have a look at +attr_accessible+.
+ #
+ # Note that using <tt>Hash#except</tt> or <tt>Hash#slice</tt> in place of +attr_protected+
+ # to sanitize attributes won't provide sufficient protection.
+ def attr_protected(*keys)
+ use_authorizer(:protected_attributes)
+ protected_attributes.merge(keys)
+ end
+
+ # Specifies a white list of model attributes that can be set via
+ # mass-assignment, such as <tt>new(attributes)</tt>,
+ # <tt>update_attributes(attributes)</tt>, or
+ # <tt>attributes=(attributes)</tt>
+ #
+ # This is the opposite of the +attr_protected+ macro: Mass-assignment
+ # will only set attributes in this list, to assign to the rest of
+ # attributes you can use direct writer methods. This is meant to protect
+ # sensitive attributes from being overwritten by malicious users
+ # tampering with URLs or forms. If you'd rather start from an all-open
+ # default and restrict attributes as needed, have a look at
+ # +attr_protected+.
+ #
+ # class Customer < ActiveRecord::Base
+ # attr_accessible :name, :nickname
+ # end
+ #
+ # customer = Customer.new(:name => "David", :nickname => "Dave", :credit_rating => "Excellent")
+ # customer.credit_rating # => nil
+ # customer.attributes = { :name => "Jolly fellow", :credit_rating => "Superb" }
+ # customer.credit_rating # => nil
+ #
+ # customer.credit_rating = "Average"
+ # customer.credit_rating # => "Average"
+ #
+ # Note that using <tt>Hash#except</tt> or <tt>Hash#slice</tt> in place of +attr_accessible+
+ # to sanitize attributes won't provide sufficient protection.
+ def attr_accessible(*keys)
+ use_authorizer(:accessible_attributes)
+ accessible_attributes.merge(keys)
+ end
+
+ # Returns an array of all the attributes that have been protected from mass-assignment.
+ def protected_attributes
+ read_inheritable_attribute(:protected_attributes) || begin
+ authorizer = BlackList.new
+ authorizer += attributes_protected_by_default
+ authorizer.logger = logger
+ write_inheritable_attribute(:protected_attributes, authorizer)
+ end
+ end
+
+ # Returns an array of all the attributes that have been made accessible to mass-assignment.
+ def accessible_attributes
+ read_inheritable_attribute(:accessible_attributes) || begin
+ authorizer = WhiteList.new
+ authorizer.logger = logger
+ write_inheritable_attribute(:accessible_attributes, authorizer)
+ end
+ end
+
+ def mass_assignment_authorizer
+ protected_attributes
+ end
+
+ private
+
+ # Sets the active authorizer, (attr_protected or attr_accessible). Subsequent calls
+ # will raise an exception when using a different authorizer_id.
+ def use_authorizer(authorizer_id) # :nodoc:
+ if active_authorizer_id = read_inheritable_attribute(:active_authorizer_id)
+ unless authorizer_id == active_authorizer_id
+ raise("Already using #{active_authorizer_id}, cannot use #{authorizer_id}")
+ end
+ else
+ write_inheritable_attribute(:active_authorizer_id, authorizer_id)
+ end
+ end
+
+ end
+end
@@ -0,0 +1,44 @@
+require 'active_record/mass_assignment_security/sanitizer'
+
+module ActiveRecord
+ module MassAssignmentSecurity
+ class PermissionSet < Set
+
+ attr_accessor :logger
+
+ def merge(values)
+ super(values.map(&:to_s))
+ end
+
+ def include?(key)
+ super(remove_multiparameter_id(key))
+ end
+
+ protected
+
+ def remove_multiparameter_id(key)
+ key.gsub(/\(.+/, '')
+ end
+
+ end
+
+ class WhiteList < PermissionSet
+ include Sanitizer
+
+ def deny?(key)
+ !include?(key)
+ end
+
+ end
+
+ class BlackList < PermissionSet
+ include Sanitizer
+
+ def deny?(key)
+ include?(key)
+ end
+
+ end
+
+ end
+end
Oops, something went wrong. Retry.

2 comments on commit 606088d

@norman
norman commented on 606088d Jul 15, 2010

This commit caused some working code in my friendly_id plugin to fail; previously accessible_attributes returned nil if attr_accessible had not been invoked; now it always returns an instance of WhiteList no matter what. Is this the desired behavior going forward, or an oversight? If this is intentional it's not a problem to update my code, I'm just curious.

@josevalim
Member

It's indeed intentional.

Please sign in to comment.