CodeAnt AI Security Research
CVE-2026-71509: Approve Your Own Expense Claims

Amartya Jha
CEO, CodeAnt AI
TL;DR
CVE-2026-71509 is a business logic and mass-assignment flaw (CWE-915) in Dolibarr ERP CRM's Expense Reports REST API, scored CVSS 6.5. An account holding only the file-a-claim right (expensereport.creer) sends a generic PUT carrying fk_statut and fk_user_approve and approves its own expense claim, approves any claim filed by someone in its reporting line, and can push a claim straight to closed, all with no approver right. The dedicated approve route refuses the same key. The generic route never checks. It is fixed in Dolibarr 24.0.0.
What is CVE-2026-71509?
CVE-2026-71509 is a business logic vulnerability in the Expense Reports REST API of Dolibarr ERP CRM, the open-source suite that runs accounting and expense workflows for small and mid-size businesses. It is filed as CWE-915, improperly controlled modification of object attributes, with a secondary of CWE-862, missing authorization.
ExpenseReportApi::put() authorizes a request with one coarse check for the filer right, then copies every remaining JSON body key onto the expense-report object with no allow-list. Two of those keys, fk_statut (the claim's lifecycle status) and fk_user_approve (the recorded approver), are workflow fields the caller is not entitled to set, and writing them is performing an approval that a different permission is supposed to govern.
So a caller holding the everyday expense-desk role gains the authority to approve claims, including its own. It carries a CVSS 3.1 base score of 6.5 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N) and was fixed in Dolibarr 24.0.0.
The Expense Approval Permission This API Bypasses
A claim is a row in llx_expensereport. Its lifecycle lives in the numeric fk_statut column, and its approver is recorded in fk_user_approve. Dolibarr keeps three authorities over that row deliberately separate. The person who submits a claim holds expensereport.creer, the person who signs it off holds expensereport.approve, and finance holds expensereport.to_paid.
The module states that control out loud. It ships dedicated approve and setPaid handlers, and each one checks the permission its name implies. Those routes are correct.
The optional REST module also exposes a generic update route at /api/index.php/expensereports/{id}, authenticated by a per-user key in the DOLAPIKEY header. That route reaches the same fk_statut and fk_user_approve fields as the dedicated approve handler, and it checks neither of the two workflow permissions.
How Dolibarr’s Generic PUT Route Approves Expense Claims
ExpenseReportApi::put() makes two authorization decisions and then trusts the body. The first, at line 506, checks the filer right. The second, at line 515, scopes which report row the caller may touch. Neither asks whether the caller may perform the transition the body encodes.
The only filter on that loop is the generic denylist in _checkValForAPI(), which rejects internal plumbing such as db, element and context but names no workflow field. The values then reach the row after integer coercion only.
update() takes $user only to stamp fk_user_modif. It never consults that user's rights. So a body of {"fk_statut":5,"fk_user_approve":<id>} sets the approved status with an approver of the attacker's choosing, and {"fk_statut":6,...} writes the closed status directly.
Why the Ownership Check Still Lets Managers Approve Claims
The second gate on the path, _checkAccessToResource() at line 515, looks like a safety net and is not. It is an ownership test, not a function-level one. For this feature it resolves to checkUserAccessToObject() in security.lib.php, which only checks that the report's fk_user_author appears in $user->getAllChildIds(1).
That call returns the caller plus their whole subordinate tree. So the check passes for the caller's own reports and for every report filed by anyone who reports up to them.
That is why the same request that approves your own claim also approves a subordinate's. A team lead deliberately denied the approver right can sign off every claim their reports file, which is the exact boundary the approver permission exists to draw.
How CVE-2026-71509 Works: From Expense Filer to Approver
Every request below ran against a throwaway Dolibarr in local Docker, hardened to a production profile with install locked, production mode on, HTTPS forced, and CSRF at its strictest. None of that hardening touched the outcome. The actor is a non-admin holding exactly expensereport.lire and expensereport.creer, without approve, to_paid or admin, plus its own API key. The fixture adds a claimant A, a subordinate B who reports to A, and one draft report each.
The controls come first. Sent through the routes that name the permission, the same transition is refused. POST /expensereports/{id}/approve and POST /expensereports/{id}/setpaid both return 403 (Forbidden: Insufficiant rights), the generic PUT from a key lacking creer returns 403, and the same request with no credentials returns 401.
Now the generic route, same key, same object, one different path. A PUT /api/index.php/expensereports/{SELF} carrying {"fk_statut":5,"fk_user_approve":<attacker id>} returns 200, and the echoed object reads "status": 5 with "fk_user_approve" set to the caller's own user id. The caller has approved its own claim.
The same request against the subordinate's report, the one the dedicated approve route just refused, also returns 200, now with "fk_user_author" naming user B and "fk_user_approve" naming the attacker. Reading the rows back confirms both moved from draft to the approved status, and both were the only rows in the queue load_board() selects for payment, 12,000.00 combined.
date_approve stayed NULL, and fk_user_modif was stamped with the real caller. A final PUT carrying {"fk_statut":6,"fk_user_approve":<id>,"paid":1,"fk_c_paiement":<mode>} wrote the closed status straight from draft, with no validation, approval or payment in between.
Expected vs observed. The generic PUT should treat fk_statut, fk_user_approve, fk_user_valid and fk_c_paiement as workflow-controlled, refusing them on this route or accepting the approval fields only from a caller holding expensereport.approve and the payment fields only from one holding expensereport.to_paid. Instead every one of those transitions returned 200, from the same key the approve and setpaid routes had refused minutes earlier.
What an Attacker Can Do With Expense Report Approval Rights
expensereport.creer is the everyday grant for anyone who files expenses, which is close to everyone. This bug turns that routine right into approval authority.
Self-approval removes the control that keeps a filer from paying themselves, so expensereport.approve becomes a label rather than a check. The subtree branch extends it to a whole team, because a manager reaches every claim their reports file.
Fraudulent claims arrive at the payment desk pre-approved, since the approved status is exactly what the to-pay workboard selects, and both manipulated reports entered it carrying an approver name. The recorded approver is attacker-supplied, because fk_user_approve comes from the request body rather than the session, so a claim can name any user id while fk_user_modif still holds the real caller.
The single reliable tell is the blank approval date. The legitimate path always writes date_approve through setApproved(), and this route never does, so approved rows with a null approval date are the signature.
How Dolibarr Fixed CVE-2026-71509
Dolibarr 24.0.0 brings the generic route under the discipline the dedicated handlers already had. Workflow fields are refused on the update route rather than assigned through it, the approval fields are gated on expensereport.approve and the payment fields on expensereport.to_paid, and status changes route through the state-machine methods that write the audit fields and fire the EXPENSE_REPORT_APPROVE trigger this path skipped.
The pattern the User API already uses is the model. UserApi::put() throws a 405 for api_key and the password columns rather than copying them, and the expense route needed the same treatment for fk_statut, fk_user_approve, date_approve, fk_user_valid, paid and fk_c_paiement.
The broader lesson outlives this endpoint. When an object has a status column and a permissioned state machine, a generic mass-assignment write that reaches that column is performing a transition without asking who may. Writing fk_statut = 5 is an approval, and it should require the permission approval requires.
A Filer Could Approve Their Own Expense Claim
CVE-2026-71509 turns an ordinary expense-filing permission into approval authority. A user who cannot use Dolibarr's dedicated approval endpoint can still approve their own claim, or claims in their reporting tree, by writing workflow fields through the generic PUT API.
The root cause is simple: Dolibarr protected the endpoint, but not the workflow fields inside it. fk_statut and fk_user_approve were treated like editable data even though changing them performs an approval action.
CodeAnt AI Security Research found CVE-2026-71509 as one of nine authorization findings in a Dolibarr assessment. The finding required more than checking whether the API returned 403 or 200; it required comparing what each route was allowed to do against what the modified database state actually meant. If a generic API can write a workflow status, test that field like an API action, not like ordinary data.
This research is part of an ongoing effort by CodeAnt AI Security Research to audit widely-used open-source packages for security vulnerabilities. We believe the open-source ecosystem deserves better tools, better auditing, and more support for the maintainers who keep it running. More findings will be published as patches ship and coordinated disclosure timelines are met.
If you are a maintainer and have been contacted by our team, thank you for your work. If you believe your package may be affected by a similar pattern, we’d love to help: securityresearch@codeant.ai
Related reading: This finding is one node in the split-brain authorization pattern, the pillar behind all nine findings. It rides the same blind mass-assignment shape as CVE-2026-71504, where the same unfiltered write loop in the Members API sets a linked user's password and reaches administrator. Here the fields written are workflow states rather than credentials, and the result is a filer approving their own claim and their whole team's.
FAQs
What is CVE-2026-71509?
Can CVE-2026-71509 let a user approve their own expense claim?
What is a business logic vulnerability, and how does CVE-2026-71509 illustrate it?
How does an employee approve a whole team's claims, not just their own?
What is the difference between the dedicated approve route and the generic PUT here?
What privileges does an attacker need to exploit CVE-2026-71509?

Get Pentest Report
