DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

They scoped the customer and forgot the customer's ledger — a High-severity cross-tenant BOLA in Open Food Network

TL;DR

  • What: POST /api/v1/customer_account_transaction in Open Food Network authorized against the class, not the record. It took customer_id straight from the request body, so a manager of Enterprise A could create transactions against Enterprise B's customers — and read the resulting balance back.
  • Impact: Arbitrary credit/debit of another enterprise's customer ledger, plus disclosure of that customer's running balance. CWE-639 + CWE-862, CVSS 7.1 (High), integrity High.
  • The part I like: the parent Customer model was correctly scoped per enterprise. Only the child transaction wasn't. And a feature-flag removal in 5.7.1 quietly made the bug reachable on every instance.
  • Fixed in: OFN 5.7.4. Advisory GHSA-7cqp-qvh5-7x85, published 28 July 2026, credited to me as reporter. CVE pending.

Why you should care

Open Food Network is the software behind food hubs, farmer co-ops and buying groups in dozens of countries — small producers selling directly to the people who eat their food. An "enterprise" in OFN is a shop. A customer account transaction is money: the running balance a shop keeps for a regular customer, the store credit, the amount owed.

So this is not an abstract data-leak. One shop could reach into another shop's books and change what a customer owed, then read the new balance to confirm it worked. In a network of small businesses that trust a shared platform to keep their ledgers straight, that is about as direct a violation as you can get.

It is also a very ordinary bug. That is the point of writing it up.

The setup

OFN is Ruby on Rails, built on Spree, using CanCanCan for authorization. CanCanCan lets you declare abilities two ways, and the difference between them is the whole story here.

You can grant an ability on a class:

can :create, CustomerAccountTransaction
Enter fullscreen mode Exit fullscreen mode

That says "this user may create transactions." Any transaction.

Or you can grant it with a scope:

can :update, Customer, enterprise_id: Enterprise.managed_by(user).pluck(:id)
Enter fullscreen mode Exit fullscreen mode

That says "this user may update customers, but only those belonging to enterprises they manage." CanCanCan will enforce the condition when you authorize an actual record.

Both forms are one line. They look almost identical in a diff. They are not remotely the same.

The bug

Here is the ability grant, at app/models/spree/ability.rb:488-490:

def add_customer_account_transaction_abilities(_user)
  can [:admin, :create, :index], CustomerAccountTransaction
end
Enter fullscreen mode Exit fullscreen mode

Look at the parameter: _user. The leading underscore is Ruby's convention for "I am deliberately ignoring this argument." The method receives the user and does nothing with it. The grant is class-wide, handed to anyone who satisfies can_manage_enterprises? — that is, anyone who manages at least one enterprise anywhere on the platform.

Then the controller, app/controllers/api/v1/customer_account_transaction_controller.rb:

def create
  authorize! :create, CustomerAccountTransaction   # <-- the CLASS, not the record
  ...
  transaction = CustomerAccountTransaction.new(customer_account_transaction_params)
end

def customer_account_transaction_params
  params.require(:customer_account_transaction).permit(:customer_id, :amount, :description)
end
Enter fullscreen mode Exit fullscreen mode

authorize! is passed the class. It answers "may this user create transactions in general?" — yes — and never looks at which customer. The customer_id arrives in the request body and is used as-is.

The model then does the damage on the way in:

# app/models/customer_account_transaction.rb
before_create :update_balance
Enter fullscreen mode Exit fullscreen mode

and the serializer hands the result back:

# app/serializers/api/v1/customer_account_transaction_serializer.rb
attributes :amount, :balance
Enter fullscreen mode Exit fullscreen mode

So the write lands, the running balance is recomputed, and the new balance is returned in the response. Integrity impact and confidentiality impact in a single request.

The "aha"

Two things make this worth more than a shrug.

First, the guarded sibling. The parent object was scoped correctly all along, at ability.rb:434-435:

can [:admin, :index, :update, :destroy, :show], Customer,
    enterprise_id: Enterprise.managed_by(user).pluck(:id)
Enter fullscreen mode Exit fullscreen mode

And CustomersController backs it with a per-object check plus Customer.visible.managed_by(current_api_user). Somebody thought carefully about customer isolation and implemented it properly.

They just did it on Customer and not on the transactions hanging off Customer. That asymmetry is the strongest evidence a finding is a genuine oversight rather than a design decision — when one sibling is guarded and the other isn't, nobody chose the gap. Whenever I audit an authorization model, the scoped resources are the map: the interesting question is always which of their children didn't inherit the scoping.

Second, and this one I did not expect: the bug got easier to reach over time. From the advisory:

For Version < 5.7.1 the v1 API needs to be enabled (api_v1 feature flag; off by default). In version 5.7.1 the api_v1 feature flag was removed. For >= 5.7.1 there is no precondition.

When I reported this, the /api/v1 surface sat behind a feature toggle that was off by default, which is a real mitigating factor — an admin had to switch it on per instance. Then 5.7.1 graduated the v1 API and deleted the flag. Perfectly reasonable release engineering. It also silently promoted a gated vulnerability into an ungated one on every upgraded instance, and nobody re-examined the endpoints behind the flag when the flag came down.

Removing a feature flag is a change in attack surface. It rarely gets reviewed like one.

Proof of concept (benign)

Two enterprises, A and B. Each has a manager and a customer. Authenticate as A's manager and post a transaction against B's customer:

curl -s -X POST http://localhost:3000/api/v1/customer_account_transaction \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $MANAGER_A_TOKEN" \
  -d '{"customer_account_transaction":
        {"customer_id": 2, "amount": 1.00, "description": "marker"}}'
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 201 Created
{ "data": { "attributes": { "amount": "1.0", "balance": "1.0" } } }
Enter fullscreen mode Exit fullscreen mode

Customer 2 belongs to Enterprise B. The manager authenticating here manages only Enterprise A. The 201 is the write; the balance in the response is the read.

The amount is 1.00 and the description is marker for a reason — the goal is to demonstrate the boundary is crossed, not to move money around. A one-cent marker proves the same thing a large transfer would.

The fix

The maintainers shipped PR #14508 and asked me to review it, which I think is the right instinct and does not happen as often as it should. The change is four lines:

def create
  authorize! :create, CustomerAccountTransaction

  customer = Customer.find(customer_account_transaction_params[:customer_id])
  authorize! :update, customer          # <-- per-record check on the resolved object
  ...
Enter fullscreen mode Exit fullscreen mode

The class-level check stays, which is fine — it is harmless on its own. What matters is the second authorize!, which resolves the customer from the body-supplied id and then authorizes that record. Because Customer already carries the enterprise_id scope shown earlier, CanCanCan now refuses the cross-enterprise case for free. The fix reuses the isolation that was already there.

I rebuilt the PR branch locally and re-ran my PoC against it: the cross-enterprise request that previously returned 201 now returns 401, and the target customer's balance stays 0. Same-enterprise transactions still succeed, so the fix isn't over-broad. Verdict: NOT_REPRODUCED on the patched branch.

Shipped in 5.7.4.

Takeaways

  • Authorize the record, not the class. authorize! :create, Model and authorize! :update, record look like the same defensive habit and defend against completely different things. If a request body contains an id, something must authorize the object that id resolves to.
  • Hunt the children of guarded parents. When you find a properly scoped resource, the vulnerability is usually one relationship away — in the child records that inherit the parent's data but not the parent's authorization. The asymmetry is the tell.
  • Treat feature-flag removal as an attack-surface change. Code behind a default-off flag receives less scrutiny, and that debt comes due the moment the flag disappears. When you graduate a surface, re-audit it as if it were new — because for most of your users, it is.
  • An underscore parameter is worth a second look. def add_..._abilities(_user) is a small, honest signal that a grant ignores the user. In an authorization file, that is worth grepping for.

Disclosure timeline

  • 2026-06 — Found via source review of the CanCanCan ability model, confirmed against v5.7.0, and reported privately through GitHub's private vulnerability reporting.
  • 2026-07-07 — Maintainer opened fix PR #14508 and requested review. Rebuilt the branch and re-ran the PoC: cross-enterprise request now 401, balance unchanged, same-enterprise control still 201.
  • 5.7.4 — Fix released.
  • 2026-07-28 — Advisory GHSA-7cqp-qvh5-7x85 published by the OFN team, High 7.1, credited to me as reporter. CVE pending.

Credit where it is due: OFN triaged, fixed, and published this without any chasing, and looped me into reviewing the patch. That is what a healthy disclosure looks like.

Credit / CTA

If you run Open Food Network, upgrade to 5.7.4+. If you are on 5.7.1 through 5.7.3, note there is no workaround — the feature flag that used to gate this is gone.

If you write Rails and CanCanCan: grep your ability file for can :action, Model with no conditions hash, then check whether the controller authorizes a record or just the class. That pairing is where these live.


Santosh Kumar Puppala — AI/ML Platform Architect and independent security researcher. GitHub: @Santoshkumarpuppala

Top comments (0)