29 November 2021

Gotcha: Rails params aren't always strings

In Rails, if you assume params[:key] is always a string, you might be making your app insecure!

Rico Sta. Cruz @rstacruz

Today I came across an interesting issue in a Rails app. A simple params[:key] was throwing an error.

def index
  @page = params[:page].to_i
  @articles = Article.page(@page)
NoMethodError: (undefined method `to_i' for ["1"]:Array)
Did you mean? to_s

Why that happens

It turns out that while params[:something] is often assumed to be either a string or nil, but that isn’t always the case. It can also become arrays or hashes.

# params[:page] is a string

# params[:page] is an array

# params[:page] is a hash
Passing ?page[] or ?page[string] will automatically turn parameters to either arrays or hashes.

Security issues ahead

Whenever using params[:key], it would be wise to think “what if an array/hash is passed here?“. In this hypothetical example, the intention might be to delete one record, but it might unintentionally allow multiple deletions.

class PostController < ApplicationController
  def destroy
    # Delete a post with a given ID
    Post.where(id: params[:id]).destroy
# Deletes one post
DELETE /posts/1234

# Deletes many posts...?
DELETE /posts/1234?id[]=1&id[]=2&id[]=3&...
Thankfully this won’t work, because Rails has #destroy_all for collections rather than #destroy.

Solution: strong parameters

Rails 5’s new Strong Parameters feature prevents from issues like this. Using #permit will prevent arrays and hashes from coming through.

class PostController < ApplicationController
  def update
    # Avoid accessing params directly like this:
    #   @post.update(email: params[:email])
    # Instead, use #permit to specify what params
    # to use:
    update_params = params.permit(:email)
    @post.update(email: update_params[:email])

Using permit

Using params.permit will reject hashes and arrays.

Parameters = ActionController::Parameters

Parameters.new(email: "hi@example.com").permit(:email)
# => { email: "hi@example.com" }

Parameters.new(email: ["ATTACK"]).permit(:email)
# => { email: nil }

Parameters.new(email: {"ATTACK" => 1}).permit(:email)
# => { email: nil }

Using require

In contrast, using params.require will only let hashes and arrays through. Using both permit and require can be used to define the shape of the expected input.

Parameters = ActionController::Parameters

# => Error:
#    ActionController::ParameterMissing: param is
#    missing or the value is empty: person

Parameters.new({ person: nil }).require(:person)
Parameters.new({ person: "\t" }).require(:person)
Parameters.new({ person: {} }).require(:person)
# ^ These are also errors
Thanks for reading! I'm Rico Sta Cruz, I write about web development and more. Subscribe to my newsletter!