Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix IDOR and missing authorization in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (Active Record scopes over SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 25 KB
On this page

Steps

  1. 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.
  2. 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.
  3. Put the tenant condition in the scope for every role; an admin gets a wider scope inside the tenant, not a bypass.
  4. For batch actions, load the requested IDs through the scope and refuse the whole request when any of them is missing.
  5. 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.
  6. Add regression tests for another member, another tenant and another tenant's admin on every route.

Vulnerable, fixed and wrong

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

Vulnerableapp/access/invoice_access/vulnerable.rbRuby
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

Fixedapp/access/invoice_access/fixed.rbRuby
# 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

Wrong fixapp/access/invoice_access/wrong.rbRuby
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

app/controllers/invoices_controller.rbRuby
# 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"
end

The regression test

The 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.

Testtest/invoices_test.rbRuby
# 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

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix IDOR and missing authorization in Ruby on Rails
ToolRuleWhat it found here
Semgrepruby.rails.security.brakeman.check-unscoped-find.check-unscoped-findNo 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>
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo 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/
BrakemanUnscopedFindUnscoped findNo 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)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (Active Record scopes over SQLite)
rails (railties, actionpack, activerecord)8.1.4
sqlite32.9.6 (bundles SQLite 3.53.2)
csv3.3.6
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

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.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# 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.rb

Without 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 25 KB

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

  1. OWASP Authorization Cheat Sheet
  2. OWASP Insecure Direct Object Reference Prevention Cheat Sheet
  3. OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
  4. Rails Guides: Securing Rails Applications, privilege escalation
  5. Rails Guides: Active Record Query Interface, scopes
  6. Pundit README: scopes and verify_policy_scoped
  7. Brakeman: unscoped find
  8. CWE-639: Authorization Bypass Through User-Controlled Key
  9. CWE-862: Missing Authorization

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.