Preventing mass-assignment injection in Rails
- rails
- ruby
attr_accessible
is the current Rails way to prevent mass-assignment injection, but it has some shortcomings. Here’s
a different approach to mass-assignment protection that is the laser to attr_accessible
’s shotgun.
After last weekend’s commotion over Rails mass-assignment, I did what many developers working on Rails apps likely did:
spent my Monday morning scrutinizing the use of attr_protected
and attr_accessible
in our Rails app. Sure enough, there
were some spots where attributes created long after the original model were not added to an attr_protected
. Easily
fixed with a quick swap to attr_accessible
though, right?
attr_collateral_damage
Changing an attr_protected
to an attr_accessible
in an established codebase has a tendency to break numerous calls to the
model. Why? Well, it’s all because of those nice convenience methods that use attributes=
behind the curtains:
Notification.create :user_id => current_user.id, :event_id => @event.id
Sure, we could do that in a few lines of code instead; or we could add one of those :yes_i_really_want_to_mass_assign_stuff
style attr_accessible
scopes and pass a :role
in the controller (or not, actually, since we are pre-Rails 3.1).
The thing is, there’s no user input in the code above. I don’t really want to prevent that line of code, I want
to prevent user input in code that utilizes the same underlying mechanism. attr_accessible
will prevent user input, but it will also prevent other uses that I would prefer to have available.
attr_configuration_over_convention
So I’ve gone ahead and created a model with an attr_accessible
call; now I have my locked-down model:
# == Schema Information # # Table name: tax_rules # # id :integer(4) not null, primary key # store_id :integer(4) # rate :decimal(6, 4) # postal_code :string(255) # state :string(255) # country :string(255) # created_at :datetime # updated_at :datetime # class TaxRule < ActiveRecord::Base belongs_to :store attr_accessible :rate, :postal_code, :state, :country end
… and in that locked-down model, I have a list of all the user-assignable columns. The annoying thing about this is that I have already defined the exact same list implicitly, using standard Rails conventions:
-
id
is protected from mass assignment by default. -
created_at
andupdated_at
are columns that Rails already has convention-based knowledge of (and I would argue that they should joinid
andtype
in the list of columns that areattr_protected
by default). -
store_id
is the foreign key for thestore
association.
Any other column is likely to be something I would want to expose to a user. Sure enough, the set of remaining columns is
exactly what I’m passing to attr_accessible
.
There certainly are exceptions to that - columns that control access rights, columns that record some derived or
calculated state, etc - but those are the exceptions to the implicit convention. Conversely, I may want to expose a
foreign key column to the user - typically in the form of a drop-down list of some sort, and hopefully with a
validates_associated
or custom validation to make sure their choice is reasonable. Again, this is an exception to a
far more common use case.
An alternate approach
What if we combined the implicit convention described above, with knowledge of which inputs may have come from the user? We could then prevent mass assignment of user input into a foreign key column, and - better yet - we could prevent it without any configuration.
Such an approach certainly wouldn’t be a replacement for attr_protected
and attr_accessible
: I still don’t want
anyone - be they user or programmer - mass assigning my id
columns (or my created_at
columns, for that matter). However,
this approach is much more specific in what it is preventing, and could be on by default - regardless of whether the
programmer remembers to use attr_protected
or attr_accessible
.
Let’s take a look at how it could work:
Step 1: Apply a fresh coat of taint
Ruby already deals with an analogous problem - String
s that may have come from user input - by allowing objects to
be marked as tainted?
. In this case, our user input is coming from the params
hash. Fortunately, all the params in
the params
hash that are from the user are String
s, so we can taint
away:
module ActionDispatch::Http::Parameters private def normalize_parameters_with_taint(value) normalize_parameters_without_taint(value).tap do |params| taint_params params end end alias_method_chain :normalize_parameters, :taint protected def taint_params(params) if params.is_a? Hash params.each_pair do |key, val| taint_params val end elsif params.is_a? Array params.each do |val| taint_params val end end params.taint end end
Now we can see what is tainted,
params[:tax_rule][:store_id].tainted? # => true
and we can go about putting our new knowledge to use.
Step 2: Prevent tainted mass-assignments of foreign keys
Rails provides a framework for mass-assignment security in ActiveModel::MassAssignmentSecurity
and
ActiveModel::MassAssignmentSecurity::Sanitizer
. Using them as an example, we can easily build a hybrid of the
two for our purposes:
module TaintedMassAssignmentSecurity protected def sanitize_for_mass_assignment(attributes) sanitized_attributes = sanitize attributes debug_protected_attribute_removal attributes, sanitized_attributes super(sanitized_attributes) end def sanitize(attributes) attributes.reject do |key, value| value.tainted? && deny?(key) end end def deny?(column_name) belongs_to_associations = self.class.reflect_on_all_associations :belongs_to association = belongs_to_associations.detect do |reflection| reflection.primary_key_name.to_s == column_name end association.present? end def debug_protected_attribute_removal(attributes, sanitized_attributes) removed_keys = attributes.keys - sanitized_attributes.keys warn!(removed_keys) if removed_keys.any? end def warn!(attrs) Rails.logger.debug "WARNING: Can't mass-assign tainted foreign keys: #{attrs.join(', ')}" end end ActiveRecord::Base.send :include, TaintedMassAssignmentSecurity
With two relatively simple chunks of code, we now prevent user input from being mass-assigned into foreign key columns.
user_mass_assignable
We can also add some ways to allow for exceptions to our new security:
module TaintedMassAssignmentSecurity extend ActiveSupport::Concern included do class_attribute :_user_mass_assignable_attributes # Allows :user_mass_assignable => true to be added to a belongs_to call valid_keys_for_belongs_to_association << :user_mass_assignable end module ClassMethods def attr_user_mass_assignable(*names) self._user_mass_assignable_attributes = self.user_mass_assignable_attributes + names.map(&:to_s) end def user_mass_assignable_attributes self._user_mass_assignable_attributes ||= [] end end protected def sanitize_for_mass_assignment(attributes) sanitized_attributes = sanitize attributes debug_protected_attribute_removal(attributes, sanitized_attributes) super(sanitized_attributes) end def sanitize(attributes) attributes.reject do |key, value| value.tainted? && deny?(key) end end def deny?(column_name) return false if self.class.user_mass_assignable_attributes.include? column_name belongs_to_associations = self.class.reflect_on_all_associations(:belongs_to) association = belongs_to_associations.detect do |reflection| reflection.primary_key_name.to_s == column_name end association.present? && !association.options[:user_mass_assignable] end def debug_protected_attribute_removal(attributes, sanitized_attributes) removed_keys = attributes.keys - sanitized_attributes.keys warn!(removed_keys) if removed_keys.any? end def warn!(attrs) Rails.logger.debug "WARNING: Can't mass-assign tainted foreign keys: #{attrs.join(', ')}" end end ActiveRecord::Base.send :include, TaintedMassAssignmentSecurity
Now, we can expose our foreign keys to the user, should we so desire:
class ItemInquiry < ActiveRecord::Base belongs_to :user belongs_to :item, :user_mass_assignable => true validates_associated :item end # ... and elsewhere: current_user.item_inquiries.create params[:item_inquiry]
or:
class UserDefinedCategory < ActiveRecord::Base acts_as_tree # calls belongs_to :parent attr_user_mass_assignable :parent_id end # ... and elsewhere: UserDefinedCategory.create params[:user_defined_category]
There is no step 3
This approach isn’t a replacement for those places where the full ramifications of attr_accessible
are desirable. It
simply provides a minimal safety net that stays out of the developer’s way as much as it can. It can even be dropped in
to an existing Rails app with minimal pain. What do you think; would it make sense to for this to be in Rails itself?