Load records through a scope built from the signed-in user, never by ID alone: visible_invoices(current_user).find(params[:id]), where the scope applies the tenant to everyone and the owner to members. Use the same scope for show, index and export, and let find raise RecordNotFound, so outside the scope and missing both answer 404.
- Find every Model.find(params[:id]), find_by(id: ...), where(id: params[:ids]) and Model.all that serves a request: each is a place where the ID alone decides access.
- Write one scope per model and user (visible_invoices(user), or current_user.invoices when only owners may read) and start every query from it. find on a scope raises ActiveRecord::RecordNotFound, which Rails answers with 404.
- Put the tenant condition in the scope for every role; an admin gets a wider scope inside the tenant, not a bypass.
- For batch actions, load the requested IDs through the scope and refuse the whole request when any of them is missing.
- With Pundit, start queries from policy_scope(Invoice) and add after_action :verify_policy_scoped, which raises when an action forgot; its README calls this a development aid, not a security control.
- Add regression tests for another member, another tenant and another tenant's admin on every route.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def self.find_invoice(user, id) = Invoice.find(id)
def self.list_invoices(user) = Invoice.where(tenant: user.tenant).order(:id)
def self.export_invoices(user, ids) = Invoice.where(id: ids).order(:id)With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).
The fix
# Members read their own invoices; billing admins read every invoice in their own tenant.
# The tenant condition applies to everyone, admins included.
def self.visible_invoices(user)
invoices = Invoice.where(tenant: user.tenant)
user.billing_admin? ? invoices : invoices.where(owner: user)
end
def self.find_invoice(user, id) = visible_invoices(user).find(id)
def self.list_invoices(user) = visible_invoices(user).order(:id)
def self.export_invoices(user, ids) = visible_invoices(user).where(id: ids).order(:id)With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
def self.can_read?(user, invoice) = invoice.owner_id == user.id || user.billing_admin?
def self.find_invoice(user, id)
invoice = Invoice.find(id)
raise ActiveRecord::RecordNotFound unless can_read?(user, invoice)
invoice
end
def self.list_invoices(user) = Invoice.order(:id).select { |invoice| can_read?(user, invoice) }
def self.export_invoices(user, ids) = Invoice.where(id: ids).order(:id).select { |invoice| can_read?(user, invoice) }With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).
The controller: show, index and export
# Every action asks InvoiceAccess for the invoices, passing the signed-in user from
# ApplicationController#authenticate. find raises RecordNotFound, which Rails answers with 404.
def index
render json: InvoiceAccess.current.list_invoices(current_user).map(&:row)
end
def show
# Missing and not yours get the same answer, 404, so IDs cannot be probed.
render json: InvoiceAccess.current.find_invoice(current_user, params.expect(:id)).row
end
def export
ids = parse_ids(params[:ids])
return render json: { error: "ids must be 1 to #{MAX_EXPORT_IDS} invoice IDs, comma-separated" }, status: :bad_request unless ids
invoices = InvoiceAccess.current.export_invoices(current_user, ids).to_a
# All or nothing: one missing or unreadable ID refuses the whole export, so a partial file is
# never mistaken for the full one.
return head :not_found unless invoices.size == ids.size
send_data to_csv(invoices), type: "text/csv; charset=utf-8", disposition: "inline"
endThe exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: other users, other tenants and other tenants' admins must get nothing.
# Denied looks exactly like missing: 404 and none of the invoice's data.
def assert_hidden(user, path, number)
res = as(user, path)
assert_equal 404, res.status, "expected 404, got #{res.status}: #{res.body}"
refute_includes res.body, number, "response leaked #{number}"
end
test "exploit: member cannot read a colleague's invoice in the same tenant" do
assert_hidden("alice", "/invoices/502", "INV-502")
end
test "exploit: member of another tenant cannot read the invoice" do
assert_hidden("erin", "/invoices/501", "INV-501")
end
test "exploit: billing admin cannot read an invoice in another tenant" do
assert_hidden("cara", "/invoices/601", "INV-601")
end
test "exploit: member's list leaves out colleagues' invoices" do
assert_equal [501, 503], listed_ids("alice")
end
test "exploit: billing admin's list stays inside their tenant" do
assert_equal [501, 502, 503], listed_ids("cara")
end
test "exploit: export that includes a colleague's invoice is refused whole" do
assert_hidden("alice", "/invoices/export?ids=501,502", "INV-502")
end
test "exploit: billing admin's export into another tenant is refused whole" do
assert_hidden("cara", "/invoices/export?ids=502,601", "INV-601")
end- Authentication is not authorization: a before_action that requires sign-in leaves find(params[:id]) open to every signed-in user.
- An owner-or-admin check written into each action is easy to get almost right: this card's wrong implementation forgets the tenant in the admin branch, so a billing admin reads every tenant's invoices.
- Filtering in Ruby after loading (Invoice.all.select { ... }) still loads every tenant's rows; filter in the query, where the scope belongs.
- Answer 404, not 403, for records outside the scope, so IDs cannot be probed; find on a scope does this for you.
- Brakeman's UnscopedFind check is optional (-A) and only sees find with params in a controller on a model with a belongs_to; it cannot know your tenant rules.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/ruby-rails does this for you, in the pinned Ruby container).
- Sign in as a member, a member of another tenant and another tenant's admin, request each other's invoices on every route and expect 404 with none of the data.
- Run brakeman -A for UnscopedFind and Semgrep's check-unscoped-find; both flag only find(params[:id]) written in the controller, so the regression tests are the control for scopes that are wrong rather than missing.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | ruby.rails.security.brakeman.check-unscoped-find.check-unscoped-find | No findings on this card, where the ID reaches Invoice.find through a method argument. On a one-file probe it flagged Invoice.find(params[:id]) in a controller, but not Invoice.find_by(id: params[:id]) or the export's where(id: ids).Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id> |
| Semgrep | p/ruby, p/owasp-top-ten, p/security-audit | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/ |
| Brakeman | UnscopedFindUnscoped find | No warnings on this card. UnscopedFind is an optional check (brakeman -A) for find and find_by(id:) with request input on a model that belongs_to another; on a one-file probe it reported Invoice.find(params[:id]) and Invoice.find_by(id: params[:id]) (Weak confidence), and not current_user.invoices.find(params[:id]). It cannot see the tenant rule the wrong implementation forgets.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included) |
| Component | Version |
|---|---|
| Language | Ruby |
| Runtime | Ruby 4.0.7 |
| Framework | Ruby on Rails 8.1.4 (Active Record scopes over SQLite) |
| rails (railties, actionpack, activerecord) | 8.1.4 |
| sqlite3 | 2.9.6 (bundles SQLite 3.53.2) |
| csv | 3.3.6 |
| minitest | 6.0.6 |
| minitest-reporters (JUnitReporter) | 1.8.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/ruby-railsfolder.
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/invoices_test.rb
# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/invoices_test.rb
# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/invoices_test.rbWithout Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/invoices_test.rb'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/invoices_test.rb'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
sh -c 'bundle exec ruby -Itest test/invoices_test.rb'Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs missing-authorization/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
- bundler 4.0.20
- rails 8.1.4
- minitest 6.0.6
- minitest-reporters 1.8.0
- sqlite3 2.9.6 (SQLite 3.53.2)
- csv 3.3.6
- semgrep 1.180.0
- brakeman 8.1.0
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
- Test results
- fixed 15/15 pass; vulnerable 6/15 fail (6 exploit tests: everything except the billing admin's own-tenant list); wrong 3/15 fail (3 exploit tests: the billing admin's read, list and export across tenants), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
- Dependency freshness
- Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Authorization Cheat Sheet
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- Rails Guides: Securing Rails Applications, privilege escalation
- Rails Guides: Active Record Query Interface, scopes
- Pundit README: scopes and verify_policy_scoped
- Brakeman: unscoped find
- CWE-639: Authorization Bypass Through User-Controlled Key
- CWE-862: Missing Authorization