CodeAnt AI Security Research

CVE-2026-71506: Dolibarr Lets Invoice Editors Delete Customer Payments

Amartya | CodeAnt AI Code Review Platform
Amartya Jha

CEO, CodeAnt AI

TL;DR

CVE-2026-71506 is an incorrect-authorization flaw (CWE-863) in Dolibarr ERP CRM's payments REST API, scored CVSS 6.5. The DELETE /api/index.php/paiements/{id} route checks the Delete invoices permission instead of the Issue payments permission that governs money everywhere else, so a non-admin holding only invoice-deletion rights hard-deletes recorded customer payments and inflates the apparent debt on those invoices. The check runs. It just names the wrong permission. It is fixed in Dolibarr 24.0.0.

What is CVE-2026-71506?

CVE-2026-71506 is an incorrect-authorization flaw (CWE-863) in the REST API of Dolibarr ERP CRM, the open-source suite that runs invoicing and accounting for small and mid-size businesses.

The DELETE /api/index.php/paiements/{id} endpoint gates payment deletion on the Delete invoices permission rather than the Issue payments on invoices permission that governs money everywhere else. So a non-administrator who holds only invoice-deletion rights can hard-delete recorded customer payments and inflate the apparent debt on the affected invoices without ever holding the right that controls receipts.

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.

CVE-2026-71506 Impact: Money That Was There, and Then Wasn't

In Dolibarr an invoice does not store its own paid figure. What a customer has paid is the running sum of allocation rows in llx_paiement_facture, so deleting a payment does not unpay an invoice cleanly. It silently raises the balance the customer still appears to owe.

Three invoices in the test each carried 1000.00 and one recorded payment of 110.00, 220.00 and 330.00. After three DELETE calls the payment rows and their allocation rows were gone, and all three invoices read 0.00 paid against 1000.00 outstanding while still at fk_statut = 1, validated but never settled.

Nothing was reversed. The financial records were hard-deleted, with no tombstone left behind on this path.

The Permission CVE-2026-71506 Should Have Checked

The correct check is not absent from Dolibarr. It is everywhere except here. Dolibarr deliberately splits authority over invoices from authority over money, and facture.supprimer and facture.paiement are two separate, independently grantable permissions.

The web interface enforces the money one. In htdocs/compta/paiement/card.php, payment creation is gated at line 73 and deletion at line 132, both on facture.paiement. The canonical REST endpoints agree. api_invoices.class.php checks facture.paiement at lines 1752, 1879 and 2022 before it will create, update or delete a payment.

That same file's putPayment even performs an object-level check this path skips entirely. It walks getBillsArray() and calls _checkAccessToResource('facture', $id) at lines 2036-2042, refusing if any allocated invoice is outside the caller's scope.

This is the two-doors-into-one-table problem the split-brain authorization pillar describes. The UI and the API write the same llx_paiement rows and disagree about who may. The payments DELETE route is the door with the wrong lock fitted.

The Wrong Permission Check in the Payments API

Here is the whole of the vulnerable method, htdocs/compta/facture/class/api_paiements.class.php lines 227-250, trimmed to the load-bearing lines:

public function delete($id)                                       // line 227
{
    if (!DolibarrApiAccess::$user->hasRight('facture', 'supprimer')) {  // line 229 - wrong right
        throw new RestException(403);                             // line 230
    }
    // <-- no _checkAccessToResource() on the invoices this payment is allocated to
    $result = $this->paiement->fetch($id);                        // line 233
    if (!$result) { throw new RestException(404, 'Paiement not found'); }
    if ($this->paiement->delete(DolibarrApiAccess::$user) == 0) {  // line 238 - dead branch
        throw new RestException(409, ...);                        // line 239
    } elseif ($this->paiement->delete(DolibarrApiAccess::$user) <

public function delete($id)                                       // line 227
{
    if (!DolibarrApiAccess::$user->hasRight('facture', 'supprimer')) {  // line 229 - wrong right
        throw new RestException(403);                             // line 230
    }
    // <-- no _checkAccessToResource() on the invoices this payment is allocated to
    $result = $this->paiement->fetch($id);                        // line 233
    if (!$result) { throw new RestException(404, 'Paiement not found'); }
    if ($this->paiement->delete(DolibarrApiAccess::$user) == 0) {  // line 238 - dead branch
        throw new RestException(409, ...);                        // line 239
    } elseif ($this->paiement->delete(DolibarrApiAccess::$user) <

public function delete($id)                                       // line 227
{
    if (!DolibarrApiAccess::$user->hasRight('facture', 'supprimer')) {  // line 229 - wrong right
        throw new RestException(403);                             // line 230
    }
    // <-- no _checkAccessToResource() on the invoices this payment is allocated to
    $result = $this->paiement->fetch($id);                        // line 233
    if (!$result) { throw new RestException(404, 'Paiement not found'); }
    if ($this->paiement->delete(DolibarrApiAccess::$user) == 0) {  // line 238 - dead branch
        throw new RestException(409, ...);                        // line 239
    } elseif ($this->paiement->delete(DolibarrApiAccess::$user) <

Two defects sit in twelve lines. The first is the wrong key at line 229, facture.supprimer where the rest of the codebase requires facture.paiement.

The second is quieter. The underlying Paiement::delete() in htdocs/compta/paiement/class/paiement.class.php (line 699) returns 1 or a negative value and never 0, so the == 0 comparison at line 238 is dead. On the success path control falls to the elseif at line 240 and the deletion routine runs a second time, firing the PAYMENT_CUSTOMER_DELETE trigger twice.

That second call surfaces in the evidence. A bank-linked control payment was refused with a 500 whose debug source is api_paiements.class.php:241, the elseif branch, not line 238. The API layer is the only place either the permission check or the missing object-scope check can be made, and nothing downstream compensates.

Reproducing CVE-2026-71506 on a Throwaway Instance

Every request here was fired at an isolated Dolibarr container on loopback, no public host, no third party, nothing but a Docker instance built to be thrown away afterward. The actor is an internal, non-admin user (admin = 0) whose rights were read straight out of llx_user_rights to avoid taking them on trust, exactly facture.lire and facture.supprimer, nothing else. No facture.paiement.

Two controls first establish that Dolibarr's boundaries work when asked the right question. A principal holding only third-party read rights sends the identical DELETE and is refused HTTP 403, the debug envelope naming api_paiements.class.php:230, so the guard is real and firing.

Then the actor offers its own key to the canonical payment-update route, PUT /api/index.php/invoices/payments/999999. Dolibarr refuses at api_invoices.class.php:2023, forbidding this same user from so much as renaming a payment.

Now the endpoint with the wrong lock. The request is unremarkable:




The response is HTTP 200 with {"success":{"code":200,"message":"Paiement deleted"}}, repeated for the other two ids, and all three return 200. A database read-back confirms zero remaining rows in both llx_paiement and llx_paiement_facture, all three invoices at 0.00 paid, and the detail that stings, ESC-PAY-103, which carried fk_export_compta = 1, gone with the rest.

A payment already handed to the accounting export was destroyed by a caller who could not have renamed it seconds earlier.

Who Actually Holds the Vulnerable Permission?

Separation of duties is exactly why facture.supprimer and facture.paiement are distinct. You let a junior clerk clean up mistaken or duplicate invoices and deliberately keep them away from recorded receipts, which belong to whoever reconciles the bank.

Read the permission by its name and Delete invoices says nothing about money. This flaw quietly folds payment destruction into it. Any principal, a person or an integration key minted for a one-purpose script, scoped to invoice deletion on the plain meaning of that permission gains the power to wipe receipts.

The result is a clean fraud-and-cover-up primitive. One unconditional request per payment against auto-increment ids the account may enumerate legitimately, leaving open invoices dunning customers for money already paid and ledger entries pointing at receipts the ERP no longer knows about.

Unless the optional Unalterable Archives module is on, and it is off by default, nothing reaches the event log at all.

How Dolibarr Fixed CVE-2026-71506 in 24.0.0

The fix is small because the correct behavior already existed twenty lines away. Align line 229 with facture.paiement, add the getBillsArray() object-scope loop after the fetch, and capture the delete result once so the routine stops running twice. The 24.0.0 release closed the permission gap.

The broader lesson is the one this whole cluster keeps arriving at. When two surfaces independently write the same records, the permission each demands drifts apart unless something forces them to agree.

A single table-driven map from object action to required right, consulted by both layers, is what stops a route from quietly asking for the wrong key. A check that runs is not the same as a check that is correct.

The Authorization Check Was Correct, The Permission Wasn't

CVE-2026-71506 is a reminder that an authorization check can exist and still be completely wrong. Dolibarr checked permissions before deleting payments, it just checked the invoice-deletion permission instead of the payment permission.

CodeAnt AI Security Research found this as part of a broader Dolibarr assessment. Its agentic pentesting platform doesn't stop at “is there an authorization check?” It tests whether the right permission protects the right action and validates the resulting impact.

Because a 403 proves a check exists. It doesn't prove the check is correct.

Related research: This finding is one node in the split-brain authorization pattern, the pillar on why two write paths into one set of rows end up disagreeing over authority across all nine findings. Its closest sibling here is CVE-2026-71510, where Dolibarr's sqlfilters parser leaks column data one boolean at a time. Both are the same failure mode from the opposite side, a check that genuinely executes and then decides the wrong thing.

FAQs

What is CVE-2026-71506 and why is it classified as CWE-863?

What is the impact of CVE-2026-71506?

What is incorrect authorization (CWE-863), and how does CVE-2026-71506 illustrate it?

What privileges does an attacker need to exploit CVE-2026-71506?

Which Dolibarr versions are affected, and how is CVE-2026-71506 fixed?

What can an attacker do with CVE-2026-71506?

Start Your 14-Day Free Trial

AI code reviews, security and quality trusted by modern engineering teams.

Table of Content
No headings found on page
AI Native SAST and SCA

Get Pentest Report

NO CC REQUIRED