Improve confirmation token validation in Devise (CVE-2019-16109)

Devise version 4.7.1 was released with a fix for an edge case that could confirm accounts by mistake. We’ll explain now in details what is the issue, how it was fixed and which actions you might want to take in your applications.

Description

We received a security report saying that it was possible to confirm records with a blank confirmation_token parameter. In order words, hitting the following URL /users/confirmation?confirmation_token= would successfully confirm a user instead of showing a validation error – e.g. Confirmation token can't be blank.

This only happens if there are records with a blank confirmation token in the database. This is because of the way the method find_first_by_auth_conditions works (which is similar to ActiveRecord’s #find_by). It is important to mention that we haven’t found a case where Devise sets confirmation_token to an empty string.

In summary, before version 4.7.1, this is what would happen after hitting the confirmation endpoint with a blank confirmation_token:

Started GET "/users/confirmation?confirmation_token=" for ::1 at 2019-09-05 10:24:29 -0300
Processing by Devise::ConfirmationsController#show as HTML
  Parameters: {"confirmation_token"=>""}
  User Load (1.0ms)  SELECT  "users".* FROM "users" WHERE "users"."confirmation_token" = $1 ORDER BY "users"."id" ASC LIMIT $2  [["confirmation_token", ""], ["LIMIT", 1]]

Notice that the SQL query is trying to find users with confirmation_token = "".

It’s also worth mentioning that only unconfirmed records – i.e. with confirmed_at: nil in the database – would be confirmed in this case. For already confirmed users, a validation error (Email was already confirmed, please try signing in) would be displayed.

Possible implications

Considering that there are records with an empty confirmation_token in the database, a request sending a blank parameter would confirm the first record found in the database. This means that someone’s account would be confirmed by mistake.

A more sensible scenario is for applications that automatically sign in accounts after confirmation. That would not only confirm someone’s account but also give an attacker access to it. Although this feature is not included in Devise, we know that some applications might have it.

For already confirmed records – i.e. with a confirmed_at date in the database – the validation error would be displayed, but their email would be leaked to the end user inside the form’s input.

Solution

The solution was to validate whether the confirmation_token is empty before doing any query in the database. So now, when the same endpoint is hit, nothing happens:

Processing by Devise::ConfirmationsController#show as HTML
  Parameters: {"confirmation_token"=>""}
Completed 200 OK in 371ms (Views: 339.0ms | ActiveRecord: 5.9ms)

And the end-user will see the validation error on the screen.

See the pull request with the solution for more information.

Actions you might want to take

Aside from updating Devise, you might also want to check whether you have records in that state in your application. You can do that with a query like this one:

irb(main):001:0> User.where(confirmation_token: "")
  User Load (0.6ms)  SELECT  "users".* FROM "users" WHERE "users"."confirmation_token" = $1 LIMIT $2  [["confirmation_token", ""], ["LIMIT", 11]]
=> #<ActiveRecord::Relation []>

If you get results out of this query, you might want to nullify them to avoid the confirmation by mistake:

irb(main):002:0> User.where(confirmation_token: "").update(confirmation_token: nil)
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."confirmation_token" = $1  [["confirmation_token", ""]]
   (0.2ms)  BEGIN
  User Update (0.7ms)  UPDATE "users" SET "confirmation_token" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["confirmation_token", nil], ["updated_at", "2019-09-05 14:03:20.857488"], ["id", 1]]
   (1.2ms)  COMMIT

Causes

Although we received a report where someone worked in an application that had records with a blank confirmation token in the database, no code or use case was found inside Devise that would make records end up in that state.

If you find a case where this happens, please contact us at opensource@plataformatec.com.br and we’ll look at it.

Finally, we want to thank Anthony Mangano for reporting this issue and helping with the solution.