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 tookcustomer_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 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) 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 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 # = 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"}}' HTTP/1.1 201 Created { "data": { "attributes": { "amount": "1.0", "balance": "1.0" } } } 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 ... 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 andauthorize! :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 still201 . - 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)
Comments
No comments yet. Start the discussion.