The bug params.expect exists to fix

Free preview 3 min to read

The course Controller params & security pointed to

Controller params & security ended by setting expect aside: how require and permit work on their own, and how expect's single call replaces both, was left for a dedicated course, this one. Everything that course taught about require and permit still holds. This course is about expect itself.

The crash require/permit doesn't protect against

An attacker sends { catalog_course: "hack" }, a plain string where this app's Admin::Catalog::CoursesController expects a nested hash:

params = ActionController::Parameters.new(catalog_course: "hack")
params.require(:catalog_course).permit(:title_en)
# NoMethodError: undefined method 'permit' for an instance of String

.require(:catalog_course) returns the string "hack", because a string is present and not blank, and that's all .require checks. .permit, called next, doesn't exist on String. The result is an unhandled NoMethodError, not a clean 400: one of the user-triggered 500 errors that Rails' doc comment for expect says it guards against.

A quieter, more dangerous version of the same bug

params2 = ActionController::Parameters.new(user: { name: "Martin", pets: { name: "hack" } })
result = params2.require(:user).permit(:name, pets: [:name])
result[:pets]
# => #<ActionController::Parameters {"name" => "hack"} permitted: true>
# result[:pets].class => ActionController::Parameters, not an Array

pets: was declared as an array of hashes, pets: [:name], and the attacker sent a single hash instead. .permit doesn't crash this time. It returns result[:pets] as a lone Parameters object instead of the array the code downstream expects. There's no exception and no log line. Code that assumes result[:pets].each { |p| ... } raises a confusing NoMethodError on .each somewhere later. Worse, code that reads result[:pets][:name] gets what the attacker sent and works, while being wrong about what was validated.

The same two payloads, through expect

params.expect(catalog_course: [:title_en])
# ActionController::ParameterMissing: param is missing or the value is empty or invalid: catalog_course

params2.expect(user: [:name, pets: [[:name]]])
# result[:pets] => nil

The first case raises a clean ParameterMissing instead of an unhandled crash; the second returns nil instead of the wrong shape. expect checks the shape of what it's given as well as whether a key is present, which is why this app's admin/catalog/courses_controller.rb uses it.

What this course covers

Seven lessons, each run against ActionController::Parameters objects. This lesson covers the bug and its fix. Lesson 2 covers expect's one-call form, using this app's line in full. Lesson 3 explains the double-bracket [[:name]] syntax from this lesson's fix, and lesson 4 covers multiple root keys and destructuring. Lesson 5 covers expect! and a surprising detail of Rails' internals, read from the installed source. Lesson 6 migrates one of this app's require/permit controllers to expect and checks that the output is identical.

Lesson 1 of 7 · free

Keep going with lesson 2

One call, both jobs: this app's params.expect line

Reads this app's course_params line and Rails' source, showing expect is built on permit_filters and require.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...