TL;DR
-
What: In Akaunting — the open-source accounting app small businesses self-host to run invoices, bills and payments — the per-controller permission middleware only recognized the standard CRUD action names. The document
mark*actions (markSent,markCancelled,markReceived) matched none of them, so they ran with no permission check. A user holding only a read role on invoices/bills — the built-inaccountantrole — could cancel documents and delete the linked payment records. -
Impact: A read-only accountant sends one
GETand the invoice is cancelled and its recorded payment transaction is deleted — corrupting the books — while the same account is correctly refused (403) on the normal update. CWE-862. I score it CVSS v3.1 7.1 (High) (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L) — that is my scoring; there is no advisory and no vendor rating, and a scorer who treatsmarkSent/markReceivedas non-destructive state-change lands around 5–6. -
Fixed: in 3.2.0 — commit
80ef6d3b, "Added missing permissions and security fixed.." — which adds themark*verbs to theupdate-permission. Reported by me, Santosh Kumar Puppala, under coordinated disclosure. No reply, no advisory; a CVE has been requested and is pending.
Why you should care
Akaunting runs the actual books for a lot of small businesses — invoices out, bills in, payments recorded against them. It is role-based by design: you hand your bookkeeper or an external accountant an account and you trust the role to bound what they can touch. The built-in accountant role is seeded read-only on invoices and bills. Read the numbers; don't change them.
The entire value of that role is the boundary. This bug is the boundary quietly not existing on three endpoints.
And the endpoints aren't harmless toggles. Cancelling an invoice doesn't just flip a status — it deletes the payment transactions recorded against it. So the least-privileged finance role in the product could reach in and destroy financial records it was never allowed to edit.
The setup
Akaunting wires up authorization centrally. Every controller inherits from a base Controller whose constructor calls assignPermissionsToController, which maps action names to permission: middleware (app/Traits/Permissions.php:496):
$this->middleware('permission:create-'.$controller)->only('create','store','duplicate','import');
$this->middleware('permission:read-'.$controller)->only('index','show','edit','export');
$this->middleware('permission:update-'.$controller)->only('update','enable','disable');
$this->middleware('permission:delete-'.$controller)->only('destroy');
It's a tidy design. Name your controller method store and it's gated behind create-<controller>; name it destroy and it needs delete-<controller>. The accountant role is granted r on sales-invoices and purchases-bills in database/seeds/Permissions.php and nothing more — so index/show/edit pass, update/destroy don't.
You've probably already spotted the shape of the problem. This is an allowlist keyed on method name. It protects exactly the handful of names in those four only() lists. Anything else a controller exposes is, by construction, ungated.
The bug
Invoices have more actions than CRUD. You can mark an invoice sent, cancelled, or received — real state changes with real side effects. Those methods are named markSent, markCancelled, markReceived. Look back at the four lists: none of those names appears in any of them.
Sales\Invoices and Purchases\Bills add no constructor override, and the admin route group only enforces permission:read-admin-panel. So the mark* methods run with no permission check beyond "can this user see the admin area at all" — which the read-only accountant can. One controller over, Sales\RecurringInvoices gates its own state-change verbs in its constructor with permission:update-sales-invoices — so the project already decided a document mutation should require update-. The mark* actions are simply where it didn't.
The routes are plain GETs (routes/admin.php):
GET {company}/sales/invoices/{invoice}/cancelled -> Sales\Invoices@markCancelled
GET {company}/sales/invoices/{invoice}/sent -> Sales\Invoices@markSent
GET {company}/purchases/bills/{bill}/cancelled -> Purchases\Bills@markCancelled
And markCancelled is not a soft toggle. It dispatches Jobs\Document\CancelDocument:
\DB::transaction(function () {
$this->deleteRelationships($this->model, ['transactions', 'recurring']); // deletes recorded payments
$this->model->status = 'cancelled';
$this->model->save();
});
deleteRelationships($model, ['transactions', ...]) deletes the payment transactions linked to the document. A read-only role reaches that.
The permission layer protected the verbs it had names for and waved through every verb it didn't — so an action that deletes payment records inherited the access level of a read-only role, purely because of what it was called.
That framing is what separates this from a design decision. If Akaunting nowhere required update- to change a document, a maintainer could argue "the accountant role is trusted." But the sibling RecurringInvoices controller demands exactly that permission for the same kind of verb. The rule the project wants exists and is enforced elsewhere; it just isn't on this path.
Proof of concept
I confirmed this end to end against the shipped release — the official akaunting/akaunting:3.1.21 image plus MariaDB, using Akaunting's own Docker compose. Everything below runs as U, a user with the built-in accountant role (role_id 4), in company 1. U is a genuine read-only account: it gets a 403 on /1/wizard, an admin-only page.
The target is deliberately benign — one synthetic invoice (INV-POC-001, status draft) with one linked £100 payment transaction (TXN-POC-001). The "payload" is a permission test, nothing more.
state BEFORE: invoice id=1 status=draft ; transaction id=1 document_id=1 deleted_at=NULL
[CONTROL] PUT /1/sales/invoices/1 -> HTTP 403 (update-sales-invoices — correctly denied)
[ATTACK] GET /1/sales/invoices/1/cancelled -> HTTP 302 (succeeds — no permission middleware)
state AFTER: invoice id=1 status=cancelled
transaction id=1 deleted_at=2026-06-13 02:38:12 <- linked payment record deleted
The control is the point. The same user, in the same session, is refused the normal update path (PUT → 403, because the read role lacks update-sales-invoices) and then cancels the invoice and destroys its payment transaction through a GET the framework never gated. One more, for the sibling verb:
[VARIANT] (fresh draft INV-POC-002)
GET /1/sales/invoices/2/sent -> HTTP 302 ; status draft -> sent
Same shape on bills (/purchases/bills/{id}/cancelled, /received). A role provably barred from editing invoices could cancel them and delete their recorded payments.
The fix
The fix shipped in 3.2.0 — commit 80ef6d3b, message "Added missing permissions and security fixed..", 2026-07-12 — and it's the obvious one-liner: put the mark* verbs behind the same update- permission their CRUD siblings already require.
// app/Traits/Permissions.php — the update mapping, before:
$this->middleware('permission:update-'.$controller)->only('update','enable','disable');
// 3.2.0 adds the state-change verbs to that list, so markSent / markCancelled /
// markReceived now demand update-<controller> — and a read-only role gets a 403.
That is exactly the remediation I sent on June 13th, and it matches what RecurringInvoices was already doing. (I'm describing the change rather than pasting the byte-exact commit: I confirmed the commit, the fixed versions and the approach from public release metadata, not from a rebuilt-and-retested 3.2.0 image.)
Takeaways
An allowlist keyed on method name silently excludes every method you forget to name. The mapping in assignPermissionsToController is elegant, and it is the vulnerability: it defends the canonical CRUD verbs and nothing else. The moment someone adds markCancelled — a perfectly reasonable controller method — it lands outside the allowlist and ships ungated, with no error, no warning, nothing that looks wrong in review. If your framework binds authorization to names, every new method is an opt-out by default.
When a codebase already gates a verb in one place, grep for the same verb everywhere else. RecurringInvoices gated these behind update-sales-invoices. That single deliberate line is the project's own statement of intent — and it turns "is this by-design?" from an argument into a mechanical check: find the controller that does it right, then find the siblings that don't. That is how this was found, and it's a fifteen-minute audit in any RBAC app.
Read-only isn't read-only until every write path enforces it. The most dangerous account is often the one everyone assumes is safe. A role you hand out freely — the external accountant, the junior bookkeeper — is exactly the one worth pointing at your destructive endpoints.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-06-12 | Found during a source review of finance apps; live Docker PoC confirmed the same day (accountant role, DB before/after) |
| 2026-06-13 | Reported privately to security@akaunting.com per their SECURITY.md, coordinated disclosure, with full remediation guidance; CVE also requested via Snyk |
| 2026-07-12 | Fix lands — commit 80ef6d3b, "Added missing permissions and security fixed..", 29 days after the report — and ships in 3.2.0 |
| — | No reply and no advisory; a CVE has been requested and is pending |
I never got a reply, so I can't prove cause and effect — but the fix that shipped is precisely the change I proposed, down to the permission it reuses.
Credit
Reported by Santosh Kumar Puppala — GitHub: @Santoshkumarpuppala, under coordinated disclosure. No CVE has been assigned yet; one has been requested.
If you run a Laravel app that maps permissions to controller-method names, go read that mapping and then list every public method on your mutating controllers. The methods that aren't in the map are your attack surface. That's the whole audit.
Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq & Veridor). GitHub: @Santoshkumarpuppala

Top comments (0)