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)
end
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
https://example.com/?page=hello

# params[:page] is an array
https://example.com/?page[]=hello&page[]=world

# params[:page] is a hash
https://example.com/?page[a]=hello&page[b]=world
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
  end
end
# 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.

example.rb
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])
  end
end

Using permit

Using params.permit will reject hashes and arrays.

example.rb
Parameters = ActionController::Parameters

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

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.

example.rb
Parameters = ActionController::Parameters

Parameters.new({}).require(:person)
# => 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!