Strong Params and Mass Assignment
Learning Goals
- Explain the benefits and dangers of mass assignment
- Use
params.permit
to allow specific params
Setup
To get set up, run:
$ bundle install
$ rails db:migrate db:seed
This will download all the dependencies for our app and set up the database.
<iframe width="560" height="315" src=" Video Walkthroughhttps://www.youtube.com/embed/2NSsNs-sanI?rel=0&showinfo=0" frameborder="0" allowfullscreen></iframe>
Revisiting The Create Action
In the previous lesson, we used the params
hash to access data from the body
of a request, and create a new bird:
Bird.create(name: params[:name], species: params[:species])
Since our model only has two attributes, this code looks fairly reasonable. But
imagine we were building a new model, BirdWatcher
, representing the users in
our application, that has more attributes:
BirdWatcher.create(
name: params[:name],
email: params[:email],
profile_image: params[:profile_image],
favorite_species: params[:favorite_species],
admin: params[:admin]
)
While this approach for creating a new BirdWatcher
would work, it feels like a
lot of extra work to type attribute: params[:attribute]
for every single
attribute we're using! Since the .create
method expects a hash of key-value
pairs, and params
is a hash of key-value pairs, it would be much nicer to be
able to just pass in the entire params
hash and call it a day:
BirdWatcher.create(params)
However, doing so would open us up to some surprising security vulnerabilities,
so Rails would actually prevent that code from working! Let's explore why, and
see an alternate approach to working with params
.
What Is Mass Assignment?
Let's take a step back from Rails for the moment, and think back to
Object-Oriented Ruby. We could design a BirdWatcher
class of our own, without
Active Record, like so:
class BirdWatcher
attr_accessor :name, :email, :bio, :favorite_species, :admin
def initialize(args)
@name = args[:name]
@email = args[:email]
@bio = args[:bio]
@favorite_species = args[:favorite_species]
@admin = args[:admin]
end
end
Now, we can pass in one hash when creating a new BirdWatcher
, just like we
would when creating an object with Active Record:
BirdWatcher.new(
name: "Reggie",
email: "[email protected]",
favorite_species: "Crow",
bio: "Just a bird-loving guy",
admin: false
)
So far so good! Now, let's imagine that instead of passing in that hash
directly, we're getting that hash of data from a user making a request to our
API to create a new account. Pretend this params
hash is being created based
on a user making a request to our server:
params = {
name: "Emma",
email: "[email protected]",
favorite_species: "Blue Jay",
bio: "Always be birding",
admin: true
}
Ideally, a user shouldn't be able to create their own account and give themself
admin
privileges. But if we pass this entire hash of parameters to our
#initialize
method, that's exactly what will happen:
BirdWatcher.new(params)
# => #<BirdWatcher:0x00007fa635094858 @name="Emma", ... @admin=true>
Active Record works similarly: it uses mass assignment to take a hash of
key-value pairs and assign them to attributes on our models. As a result,
passing in the entire params
hash when creating a new record in our database
would open us up to the mass assignment vulnerability.
So how do we fix it?
Strong Params
First, run rails s
to start the server. Let's use Postman to create a new bird:
Route
-------
POST /birds
Headers
-------
Content-Type: application/json
Body
------
{
"name": "Blue Jay",
"species": "Cyanocitta cristata"
}
This will create a new Bird
in our BirdsController#create
action:
def create
bird = Bird.create(name: params[:name], species: params[:species])
render json: bird, status: :created
end
The approach above is a perfectly valid solution to the mass-assignment issue. Since we are explicitly specifying which attributes we'd like our new bird to be created with, there's no chance of a user updating an attribute other than name or species.
Update the create
method like so:
def create
bird = Bird.create(params)
render json: bird, status: :created
end
Then, make another request using Postman. We'll get back a
500 - Internal Server Error
as a response, with an
ActiveModel::ForbiddenAttributesError
as the exception.
This is thanks to Rails' built-in security protection against the mass assignment vulnerability in action. We can't just pass in the entire params hash, since that would mean a malicious user could potentially update attributes of our model that we don't want to give them access to.
What we can do instead is use Strong Parameters to permit only the parameters that we want to use:
def create
bird = Bird.create(params.permit(:name, :species))
render json: bird, status: :created
end
When we call params.permit(:name, :species)
, this will return a new hash with
only the name and species keys. Rails will also mark this new hash as
permitted
, which means we can safely use this new hash for mass assignment.
Try making that same request in Postman, but this time, add an id
key to the
JSON in your request body. Now the bird is successfully created but, since the
id
key was not allowed, only the name
and species
were used. The server
logs will verify this for us:
Started POST "/birds" for ::1 at 2021-05-03 07:45:33 -0400
(0.1ms) SELECT sqlite_version(*)
Processing by BirdsController#create as */*
Parameters: {"name"=>"Blue Jay", "species"=>"Cyanocitta cristata", "id"=>99, "bird"=>{"id"=>99, "name"=>"Blue Jay", "species"=>"Cyanocitta cristata"}}
Unpermitted parameters: :id, :bird
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/birds_controller.rb:12:in `create'
Bird Create (0.8ms) INSERT INTO "birds" ("name", "species", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["name", "Blue Jay"], ["species", "Cyanocitta cristata"], ["created_at", "2021-05-03 11:45:33.745635"], ["updated_at", "2021-05-03 11:45:33.745635"]]
↳ app/controllers/birds_controller.rb:12:in `create'
TRANSACTION (1.3ms) commit transaction
↳ app/controllers/birds_controller.rb:12:in `create'
Completed 201 Created in 22ms (Views: 2.5ms | ActiveRecord: 3.6ms | Allocations: 3803)
Refactoring Params
In Rails controllers there's a strong convention among developers to create a
separate private
method for strong params, like so:
class BirdsController < ApplicationController
# POST /birds
def create
bird = Bird.create(bird_params)
render json: bird, status: :created
end
# other controller actions here
private
# all methods below here are private
def bird_params
params.permit(:name, :species)
end
end
This makes our create
action a bit cleaner, and will give us the opportunity
to reuse this private method later in our update
action.
You may also have noticed that even though the request body only has this data:
{
"name": "Blue Jay",
"species": "Cyanocitta cristata"
}
Our params hash looks like this:
{
"name"=>"Blue Jay",
"species"=>"Cyanocitta cristata",
"bird"=>{
"name"=>"Blue Jay",
"species"=>"Cyanocitta cristata"
}
}
The reason for this is that Rails by default will
wrap JSON parameters as a nested hash under a key based on
the name of the controller (in our case, bird
since we're in a
BirdsController
). This is the reason that in the Rails server log, even with
our strong params in place, you'll still see Unpermitted parameters: :bird
for
our requests.
You can disable the wrap parameters feature in an individual controller:
class BirdsController < ApplicationController
wrap_parameters format: []
end
You can also disable it for all controllers if you like, by going into the
config/initializers/wrap_parameters.rb
file and updating it like so:
ActiveSupport.on_load(:action_controller) do
wrap_parameters format: []
end
Conclusion
Rails has security features in place to prevent the mass assignment vulnerability
from allowing users to update any arbitrary attribute of a model. There are two ways
we can work with params
in Rails to keep our data safe.
-
Explicitly specify the attributes we are trying to assign:
Bird.create(name: params[:name], species: params[:species])
-
Or use strong params to permit specific attributes:
Bird.create(params.permit(:name, :species))