Bullet Train Scope Validator
Bullet Train Scope Validator provides a simple pattern for protecting belongs_to
associations from malicious ID stuffing. It was created by Andrew Culver and extracted from Bullet Train.
Illustrating the Problem
By default in a multitenant Rails application, unless special care is given to validating the ID assigned to a belongs_to
association, malicious users can stuff arbitrary IDs into their request and cause an application to bleed data from other tenants.
Consider the following example from a customer relationship management (CRM) system that two competitive companies use:
Example Models
class Team < ApplicationRecord
has_many :customers
has_many :deals
end
class Customer < ApplicationRecord
belongs_to :team
end
class Deal < ApplicationRecord
belongs_to :team
belongs_to :customer
end
Example Controller
class DealsController < ApplicationController
# 👋 Not illustrated: this controller loads `@team` safely, and has a `new` and `show` action.
def create
if @team.deals.create(deal_params)
redirect_to @deal
else
render :new
end
end
def deal_params
params.require(:deal).permit(:customer_id)
end
end
☝️ Note that Strong Parameters allows customer_id
to be set by incoming requests and isn't responsible for validating the value. We also wouldn't want Strong Parameters to be responible for this, since we'd end up with duplicate validation logic in our API controllers and other places. This is a responsibility of the model.
Example Form
<%= form.collection_select(:customer_id, @team.customers, :id, :name) %>
☝️ Note that the @team.customers.all
is properly scoped to only show customers from the current team.
Example Show View
We have a deal with <%= @deal.customer.name %>!
The "Exploit"
A malicious user can:
- Begin adding a new deal to their account.
- Inspect the DOM and replace the
<select>
element forcustomer_id
with an<input type="text">
element. - Set the value to any number, particularly numbers that are IDs they know don't belong to their account.
- Submit the form to create the deal.
- When the deal is shown, it will say "We have a deal with Nintendo!", where "Nintendo" is actually the customer of another team in the system. ☠️ We've bled customer data across our application's tenant boundary.
Usage
Building on the example above, we can use Bullet Train Scope Validator to fix the problem like so:
First, add the following in our Gemfile
:
gem "bullet_train-scope_validator"
(Be sure to also run bundle install
and restart your Rails server.)
Then we add a scope: true
validation and def valid_customers
method in the model, like so:
class Deal < ApplicationRecord
belongs_to :team
belongs_to :customer
validates :customer, scope: true
def valid_customers
team.customers
end
end
If you're wondering what the connection between validates :customer, scope: true
and def valid_customers
is, it's just a convention that the former will call the latter based on the name of the attibute being validated. We've favored a full-blown method definition for this instead of simply passing in a proc into the validator because having a method allows us to also DRY up our form view to use the same definition of valid options, like so:
<%= form.collection_select(:customer_id, form.object.valid_customers, :id, :name) %>
So with that, you're done! Any attempts to stuff IDs will be met with an "invalid" Active Record error message.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/bullet-train-co/bullet_train-scope_validator. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Bullet Train Scope Validator project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.