2024

Friday, December 20, 2024

Writing

How to convert to params.expect in Rails 8.0

A practical guide to converting existing Rails controllers to use params.expect

Read here: How to convert to params.expect in Rails 8.0

After updating RubyGems.org to use the new params.expect feature in Rails 8, I thought it might be helpful to go over a few of the challenges I ran into.

Why Should I Convert?

The new expect method for filtering params protects against user param tampering that can cause hard to rescue errors.

As a quick review of the feature, when we have code like this:

user_params = params.require(:user).permit(:name, :handle)

We’re vulnerable to users calling our action like this:

post "/users", params: { user: "error" }
user_params = params.require(:user).permit(:name, :handle)
# undefined method `permit' for an instance of String

By using the new params.expect we can prevent this problem (and another I’ll discuss below) all while cleaning up our params handling.

post "/users", params: { user: "error" }
user_params = params.expect(user: [:name, :handle])
# responds as if the required :user key was not sent at all, rendering a 400 error

Remember that with valid params, the values at the expected key(s) will be returned, just like with require.

post "/users", params: { user: { name: "Martin", handle: "martinemde" }
user_params = params.expect(user: [:name, :handle])
# => { name: "Martin", handle: "martinemde" }

The Easy Part

The conversion follows a consistent pattern.

# OLD
user_params = params.require(:user).permit(:name, :handle)

# NEW
user_params = params.expect(user: [:name, :handle])

For methods that permit a mix of scalars and hashes, the conversion is also straightforward:

# OLD
user_params = params.require(:user).permit(:name, :handle, :image, address: [:street, :city])

# NEW
user_params = params.expect(user: [:name, :handle, :image, { address: [:street, :city] }])

The Challenge

The challenge comes when you have conditional or complex parameter handling, especially when you’re doing something like this:

# OLD: Conditional parameters
user_params = params.require(:user).permit(:name, :handle)
user_params[:admin] = true if current_user.admin?

# OR

# OLD: Multiple permit calls
base_params = params.require(:user).permit(:name, :handle)
admin_params = params.require(:user).permit(:admin) if current_user.admin?
user_params = base_params.merge(admin_params || {})

Solution 1: Extract All Parameters First

The simplest approach is to extract all the parameters you might need and then conditionally use them:

# NEW: Extract all potential parameters
if current_user.admin?
  user_params = params.expect(user: [:name, :handle, :admin])
else
  user_params = params.expect(user: [:name, :handle])
end

Or more concisely:

# NEW: Conditional parameter list
permitted_keys = [:name, :handle]
permitted_keys << :admin if current_user.admin?
user_params = params.expect(user: permitted_keys)

Solution 2: Use Multiple expect Calls

For complex cases, you can still use multiple expect calls:

# NEW: Multiple expect calls
user_params = params.expect(user: [:name, :handle])
user_params[:admin] = params.expect(user: [:admin])[:admin] if current_user.admin?

Though this is less elegant than the single-call approach.

Solution 3: Helper Methods

For really complex parameter handling, consider extracting logic into helper methods:

private

def user_params
  if current_user.admin?
    params.expect(user: [:name, :handle, :admin])
  else
    params.expect(user: [:name, :handle])
  end
end

Testing Your Conversion

When converting to params.expect, make sure to test edge cases:

# Test with tampered params
test "handles tampered user param as string" do
  post users_path, params: { user: "tampered" }
  assert_response :bad_request
end

test "handles tampered user param as array" do
  post users_path, params: { user: ["tampered"] }
  assert_response :bad_request
end

test "handles missing user param" do
  post users_path, params: {}
  assert_response :bad_request
end

My Experience Converting RubyGems.org

Beyond security improvements, params.expect also provides clearer intent with parameter structure explicitly declared. You get better error messages with more specific feedback, and more consistent behavior with less underspecified edge cases.

When I converted RubyGems.org to use params.expect, I found that most conversions were straightforward. Complex parameter handling became more explicit and easier to understand. We caught several edge cases we hadn’t properly handled before and the code became more self-documenting.

Converting to params.expect improves security and code clarity. Start with the simple cases, and don’t be afraid to use conditional logic or helper methods for complex parameter handling.

The key is to think about your parameter structure upfront and declare it explicitly rather than building it up incrementally.

Martin Emde

Monday, October 21, 2024

Writing

How to: Rails params.expect

A guide to the new params.expect feature in Rails 8

Read here: How to: Rails params.expect

In this post I will describe the new params.expect feature that I recently added to Rails 8 and go over how it works and how to use it.

Update: For an implementation guide, see my second post on params.expect.

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.

def update
  user_params = params.require(:user).permit(:name, :favorite_pie)

  if @user.update(user_params)
    redirect_to :user
  else
    render :edit
  end
end

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:

def update
  # OLD
  # user_params = params.require(:user).permit(:name, :favorite_pie)

  # NEW (see how it mirrors the expected params?)
  user_params = params.expect(user: [:name, :favorite_pie])

  if @user.update(user_params)
    redirect_to :user
  else
    render :edit
  end
end

The expect method behaves very similarly to require(:user).permit(:name, :favorite_pie), but it is much more explicit about exactly what the parameters should look like and rejects any params that don’t exactly match the expected structure.

Let’s be very clear about what each type of parameter looks like and how to declare them in expect:

# A Hash:
PATCH /users/1/?user[name]=martin&user[favorite_pie]=pumpkin
# params = { "user" => { "name" => "martin", "favorit_pie" => "pumpkin" } }

# An Array:
POST /pies?pies[][flavor]=pumpkin&pies[][flavor]=pecan
# params = { "pies" => [
#            { "flavor" => "pumpkin" },
#            { "flavor" => "pecan" }
#          ] }

# A String:
POST /search?q=hello+world
# params = { "q" => "hello world" }

Each parameter type needs to be declared slightly differently in expect:

# Extract a Hash containing name and favorite_pie keys
params.expect(user: [:name, :favorite_pie])

# Extract an Array containing Hashes, each with a :flavor key
# (note: the Array syntax is more explicit than `permit`)
params.expect(pies: [{ flavor: [] }])
#     Note the explicit Array format above: ^^         ^^

# Extract a String
params.expect(:q)

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

Let’s start by exploring the core problem this new feature solves: params tampering that causes uncaught exceptions.

Here’s what can happen with the traditional Rails approach when params are tampered with:

class UsersController < ApplicationController
  def update
    user_params = params.require(:user).permit(:name, :favorite_pie)

    if @user.update(user_params)
      redirect_to @user
    else
      render :edit
    end
  end
end

When params are tampered to be a String

# Normal form submission:
# params = { "user" => { "name" => "martin", "favorite_pie" => "pumpkin" } }

# Tampered to be a String:
# params = { "user" => "tampered_string" }

user_params = params.require(:user).permit(:name, :favorite_pie)
# => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash

When params are tampered to be an Array

# Tampered to be an Array:
# params = { "user" => ["tampered", "array"] }

user_params = params.require(:user).permit(:name, :favorite_pie)
# => ActionController::UnfilteredParameters: unable to convert unpermitted parameters to hash

Both of these examples show how parameter tampering can cause unhandled exceptions in your Rails application, leading to 500 errors for your users instead of graceful error handling.

What’s going on exactly?

The problem happens when params.require(:user) returns something that doesn’t respond to permit. When the user parameter is a string or array instead of a hash, permit fails.

The old workaround was something ugly like this:

# ugly
user_params = params.require(:user)
raise ActionController::ParameterMissing.new(:user, {}) unless user_params.respond_to?(:permit)
user_params = user_params.permit(:name, :favorite_pie)

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.

# better
user_params = params.fetch(:user, {}).permit(:name, :favorite_pie)
raise ActionController::ParameterMissing.new(:user, {}) if user_params.empty?

params.expect replaces all of this mess with clear, declarative syntax that also provides better error messages and handles edge cases properly.

params.expect to the rescue

The new expect method validates both the presence and structure of the parameters:

# This expects :user to be a Hash with :name and :favorite_pie keys
user_params = params.expect(user: [:name, :favorite_pie])

# If user is missing:
# => ActionController::ParameterMissing

# If user is not a Hash (e.g., String or Array):
# => ActionController::ParameterMissing

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.

Martin Emde
martinemde.com/2024 © 2025 Martin Emde v2026.7_