How to: Rails `params.expect`
As part of Cloud City Development’s ongoing effort to support Ruby open source, I’m proud to announce the new params.expect
feature recently added to Rails 8.
params: An Attack Vector
All web application programmers learn not to trust user input.
Rails has long provided a simple pattern to prevent param tampering: params.permit
. This protects our app from users that may alter params in order to insert attributes or alter behavior, like assigning admin to yourself.
This works great when users submit the form correctly and even when they try to insert extra fields, like admin=true
into the params. These attacks get filtered out.
But protecting us from correctly submitted forms and extra attributes is not enough. What if, as we regularly see on RubyGems.org, a user is trying to break your application by submitting malformed params? Problems start to emerge.
The solution in Rails 8 is the new params.expect
.
How do I use params.expect
?
If you don’t want to dig into Rails parameter filtering right now, you can simply do the following in Rails 8:
When using params.expect
, mirror the structure of the params hash that you expect.
In the example above, the key :user
will be required and the keys :name, :favorite_pie
will be permitted (as long as they are not arrays or hashes themselves).
The params.expect
method should, in most cases, fully replace permit
and require
in almost all controller code. Unless a param is optional, you probably want to use expect
.
The benefit, which I will go into in this post, is that expect will also enforce the structure of the params. If params[:user]
returns something unexpected, like a String
or an Array
, Rails will now correctly tell the client that they have submitted a malformed request, rending a 400 error.
Additionally, if you expect an array, you need to be explicit about it.
The rest of this post will dig into why and how the new syntax works, how to use it, and some of the gotchas that you might experience with this change.
The Problem of Params Tampering
I briefly alluded to the problem with the old params filtering pattern. What if the users sends this?
The result using our old code above is an error:
NoMethodError in UsersController#update
undefined method `permit' for an instance of String
It’s not so bad. The filtering did prevent bad input, but it raised a 500 error. Not great.
It probably also got reported to an exception tracking service. Also not great if you’re getting paged.
NoMethodError: undefined method ‘permit’ for instance of String
Not only is an unhandled 500 annoying, but the error raised is extremely generic. We cannot safely rescue NoMethodError
and carry on like nothing happened. We will end up suppressing errors we really want to know about.
Now, in RubyGems.org’s case, the hacker sees an unhandled 500, so they try it again. And again. And Again. And Again.
Now someone is definitely getting paged for a high 500 error rate. 😭
You could change all of your params filtering to be really careful, checking each type in the params chain before calling the next method, but this is tedious and ugly (believe me, I tried. It prompted me to push this fix upstream to Rails.)
Digging into ActionController::Parameters
internals.
The problem happens because require
does not, must not, care about what type it is returning. It cannot ensure that an instance of ActionController::Parameters
is returned whenever one is needed, but return the String
or Array
when you want it to. Unfortunately, the user agent sending the request is in control of what type is returned from require
.
Params formats are defined by a Rails standardized format compatible with www-form-urlencoded
. If you want to send a hash, an array or a string, Rails provides the following pattern for doing so:
Note that I am ignoring URI % encoding for readability.
What’s going on exactly?
Let’s try our problem query in the rails console:
As you can see, the return value of require(:user)
is "hax"
and "hax"
does not have a method named permit
.
Despite us asking nicely for users to submit a hash for the :user
key, they sent a String. Hrmph!
And what are you going to do about it?
Since these URI query string formats are controlled by the client, we need a way to filter out unexpected formats.
Our goal is the tell the user that their request is malformed by sending a 400 Bad Request error back to the client. Rails will do this automatically when ParameterMissing
is raised, so we can do that ourselves for the wrong type.
I discovered a better version when I was trying to solve this problem for RubyGems.org. It has a few flaws, but it works well enough.
The above, or something like it, is what you’ll need to do on versions of Rails before Rails 8.
The Array problem
Using permit
in this less common way shown above is better, but there is a problem with Arrays that is almost as bad as the problem we were trying to solve in the first place.
The permit
method is more permissive than we would really like. The format permit(user: [:name, :favorite_pie])
will allow either of the following params:
When permit
is given an array as the value, e.g. [:name]
, it allows either a hash with those keys, or an array of hashes with those keys. This can be inconvenient at best and may pose a security problem if use the params in a particular way.
You can solve this problem, but again this is getting ugly.
A New Syntax for Arrays in params
In order to avoid the Array problem, expect
now only accepts hashes when given the format [:name]
. If you actually want an Array, specify it explicitly with the new (and hopefully not confusing) format:
This syntax will only permit an Array and reject, with a 400 error, any other params structure (like a Hash or String).
By resolving this ambiguity in params parsing and improving the syntax and security of features used by every Rails engineer, we are able to reduce false alarms, better protect application data, and maybe reduce the number of engineers getting paged in the middle of the night.
Let’s keep making this better
Right now I’m committed to improving the Ruby, RubyGems, and Ruby on Rails ecosystems for everyone. The RubyGems.org team, and I “upstream” improvements to the gems we use as a matter of principle. We want Ruby to be the best choice for building applications. We also work closely with other ecosystems like Python and Rust, to ensure best practices across languages.
Are you and your company comfortable with your reliance on open source? To avoid worst case scenarios, your company needs at least one engineer on your team dedicating their time to open source maintenance. If you use Ruby, then you rely on the work of the RubyGems team. I’d love to work with you to help your team suceed in the Ruby ecosystem.
If you would like to work with me and my colleagues to improve or maintain the code you rely on, tald to me at Cloud City Development where you can hire our world class team.