Odoo Xendit: the payment endpoint that skipped the token check

one public route in a payment module forgot the authorization its siblings all enforce, and I drove it live

POST /payment/xendit/payment is auth=’public’ and never calls check_access_token. With no cookie, no session, and no access token, you name a transaction by its reference and Odoo charges it through Xendit using the merchant’s stored secret key. I reproduced it on a fresh odoo:19.0: one unauthenticated request flips a victim transaction from draft to error, and a valid card token would flip it to done and pull the order through fulfillment. Found with Ilyase Dehy.

how I found it: read the row, find the odd one out

Payment controllers are the best place in a big app to hunt for missing authorization, because every provider implements the same job and you only need one of them to forget the same line. In Odoo the line is payment_utils.check_access_token(token, reference, amount). It is an HMAC keyed on the server’s secret, bound to a specific transaction reference and amount, compared in constant time. It exists so that only the browser session that legitimately started a checkout can later tell the server “charge this one.”

I lined up the payment controllers and read the route signatures. Authorize.Net checks the token. Adyen’s main payment route checks it. Xendit’s own return route, in the very same file, checks it. Then the Xendit charge route, three lines long, does not. When one entry in a column of near-identical entries is missing the field they all share, you stop reading and start testing. Found with Ilyase Dehy.

the endpoint, copied out of the running container

This is the vulnerable pre-fix version, quoted as it shipped at the time of the report. It is no longer current source; the fix is further down.

This is not from a git blame or my memory. I pulled it out of the live odoo:19.0 image I tested against:

# addons/payment_xendit/controllers/main.py  (odoo:19.0, the image I tested)
@http.route('/payment/xendit/payment', type='jsonrpc', auth='public')
def xendit_payment(self, reference, token_ref, auth_id=None):
    tx_sudo = request.env['payment.transaction'].sudo().search([('reference', '=', reference)])
    tx_sudo._xendit_create_charge(token_ref, auth_id=auth_id)

Three things stacked on top of each other:

  • auth='public' means no login, no session, the request is served for anyone.
  • the transaction is fetched by reference, a value the caller puts in the body.
  • _xendit_create_charge runs on tx_sudo, a sudo() recordset, so it executes with full rights and uses the merchant’s stored Xendit secret key.

There is no line checking that the caller has any right to this transaction. Compare the protected sibling in the same file, which gates the privileged action behind the HMAC:

# xendit_return, same file
if access_token and str2bool(success, default=False):
    if tx_sudo and payment_utils.check_access_token(access_token, tx_ref, tx_sudo.amount):
        tx_sudo._set_pending()

The return route binds the caller to the transaction. The charge route binds nothing. That asymmetry is the whole bug.

the request, exactly as I sent it

The route is type='jsonrpc', so the wire format is a JSON-RPC envelope to the path, no cookie, no session, no access_token. This is the whole request:

POST /payment/xendit/payment HTTP/1.1
Host: target
Content-Type: application/json

{"jsonrpc": "2.0", "method": "call", "id": 1, "params": {
    "reference": "VICTIM-TX-001",
    "token_ref": "<attacker_token_id>",
    "auth_id": "<attacker_auth_id>"
}}

reference names the victim’s transaction. token_ref and auth_id are the attacker’s own card token and 3DS auth. Nothing in the body ties the caller to the transaction.

proving it, with a negative control first

A finding you cannot turn off on demand is noise, so I started with the control: a reference that does not exist. No authentication on any of these.

# attacker: no cookie, no session, no access_token
POST /payment/xendit/payment  reference=DOES-NOT-EXIST-9999  token_ref=probe
  -> ERROR: Expected singleton: payment.transaction()        (negative control)

search([('reference','=','DOES-NOT-EXIST-9999')]) returns an empty recordset, and calling _xendit_create_charge on an empty recordset raises Expected singleton. That error is the tell. A real reference does not error the same way:

POST /payment/xendit/payment  reference=VICTIM-TX-001  token_ref=probe
  -> result=null

So the response shape itself is an existence oracle: Expected singleton means no such transaction, null means it exists and the charge path ran. With no auth, you can sit on this endpoint and sort real references from fake ones.

Then the actual state change, on a transaction I had not touched yet so the before and after are clean:

BEFORE  ref=VICTIM-TX-003  state=draft  msg=False
attacker -> POST /payment/xendit/payment  reference=VICTIM-TX-003  token_ref=stolen_attacker_token
server   -> result=null
AFTER   ref=VICTIM-TX-003  state=error  msg=The payment provider rejected the request.
                                            Token id is invalid

One unauthenticated request flips a victim Odoo transaction from draft to error, Xendit reached with the merchant secret

Read the state_message. “The payment provider rejected the request. Token id is invalid” is Xendit talking, not Odoo. It means my unauthenticated POST made Odoo open a connection to Xendit, authenticate with the merchant’s stored secret key, and submit a charge for the victim’s transaction. The only reason money did not move is that I handed it a junk token, and Xendit refused the junk. The authorization barrier never ran. The token validity is the operational gate, not the security gate.

Then I did the test the whole hypothesis hangs on: mint a real card token on my own helper transaction, then replay it against the victim’s reference. If the server bound my token to the transaction that created it, the replay dies. It did not.

attacker -> POST /payment/xendit/payment  (NO AUTH)
            reference=VICTIM-O6S7BF       (victim's transaction)
            token_ref=69ad3279...e80  (attacker's own card token)
BEFORE   ref=VICTIM-O6S7BF  state=draft
AFTER    ref=VICTIM-O6S7BF  state=error  msg=Credit card token has already been used

“Credit card token has already been used” is a different error from “Token id is invalid”. This one means Xendit recognized my real token and rejected it only because the prior 3DS flow had already consumed it. The server took an attacker-minted token, accepted it for a transaction it had nothing to do with, and submitted the charge. With a fresh token the charge completes. That is the replay-across-transactions result, not a duplicate of the junk-token case.

what a valid token does

The charge path is deterministic once Xendit accepts the token. _xendit_create_charge posts to Xendit, the success response goes through _handle_notification_data, that calls _set_done, and _set_done triggers post-processing: the sale order confirms, a delivery picking is created, stock is reserved. In my submitted report I drove that full chain against the Xendit sandbox: a card token I minted with the merchant’s public key (Odoo prints it in the checkout HTML), 3DS cleared, then replayed on the victim’s reference. The transaction went draft -> done with provider_reference=69ad9583c06e738456832dca, a real Xendit charge id, sale order S00014 auto confirmed, picking WH/OUT/00005 assigned. That last mile depends on the sandbox 3DS flow, which is flaky, so the reliable, repeatable proof is the draft -> error above. Both share the one root: a public endpoint performs a privileged action on an arbitrary transaction with no authorization.

re-verified against current source, 2026-10-13

Read out of the checkout at 0f577f1d3 rather than taken from the closure note. addons/payment_xendit/controllers/main.py:

@http.route('/payment/xendit/payment', type='jsonrpc', auth='public')
def xendit_payment(self, reference, token_ref, access_token, auth_id=None):
    """ Kept for backward compatibility; no longer used, as card payments now go through the
    Xendit-hosted payment link instead of the inline form.

    :param str reference: The reference of the transaction.
    :param str token_ref: The reference of the Xendit token to use to make the payment.
    :param str access_token: The access token used to verify the provided values
    :param str auth_id: The authentication id to use to make the payment.
    :return: None
    """

Two things changed, not one. access_token is now a required positional parameter, so a caller omitting it gets a TypeError at dispatch rather than reaching the charge, and the route is additionally documented as dead weight: card payments moved to the Xendit-hosted payment link, so the inline form that made this reachable is gone.

That ordering matters for anyone reading the diff later. Making the parameter required is the security fix. Retiring the inline flow is what makes the route uninteresting. A fix that only did the second would have left the code path live for any deployment still on the old flow.

The endpoint is still auth='public', which is correct: a payment return has to be reachable without a session. Public was never the bug. The bug was public plus no token check, and the token check is the half that was missing.

references are guessable, which removes the last excuse

You do not need to leak references. Odoo builds them from a timestamp (tx-YYYYMMDDHHMMSS) or straight from the order name (S00014, S00015, …). Add the oracle above and you can walk the space. The “attacker must know a reference” precondition is a few minutes of requests, not a secret.

it is not only Xendit

The same shape, auth='public' plus sudo() plus no check_access_token, sits in other providers that call the charge API with the merchant’s credentials: payment_mercado_pago (/payment/mercado_pago/payments, which also takes transaction_amount from the body), payment_paypal (/payment/paypal/complete_order), and Adyen’s follow-up /payment/adyen/payments/details. I reported those in the same thread instead of farming separate submissions. The providers that are safe all carry the one guard the vulnerable ones drop.

The POS self-order kiosk flow has the same root through a different door. pos_self_order_razorpay (/pos-self-order/razorpay-fetch-payment-status/) and pos_self_order_pine_labs (/pos-self-order/pine-labs-fetch-payment-status/) do take an access_token, but it authorizes the POS config, not the order. The order is fetched by a sequential integer order_id with an existence-and-config-match check only, never a per-order authorization. So one valid kiosk token plus a guessed order_id lets a caller drive someone else’s order through add_payment and action_pos_order_paid. Same CWE-862, the fix is to bind the caller to the order with consteq on the order’s own token instead of trusting the integer id. I folded both into the same thread.

severity, honestly

I argued High on integrity. An unauthenticated request changes a financial record and pulls an order through confirmation, delivery, and stock reservation. Odoo pushed back and settled Medium: in their reading the attacker pays someone else’s order with their own card or makes it fail, a bounded outcome rather than total loss of integrity. That is a defensible call on the realistic blast radius and the report was accepted at Medium. The bug underneath does not change with the label.

By the numbers I scored it AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L = 8.6, CWE-862 (Missing Authorization): an unauthenticated request mutates a financial record (I:H) and kicks off fulfillment that reserves stock (A:L). The exact pattern has precedent: CVE-2025-14461 (CVSS 5.3, identical CWE-862 on the Xendit WooCommerce plugin, unauthenticated order completion via a missing authorization check on the callback) and CVE-2021-23178 (CVSS 7.5, Odoo ≤15.0, payment token reuse across users from missing authorization). Same missing line, three products.

fix status, verified not assumed

The fix is the one guard every sibling already carries. This is the shape it takes, the missing check_access_token dropped in before the charge:

Corrected 2026-10-13. This block was a reconstruction from memory and it was wrong in two ways: it passed three arguments to check_access_token and invented the error string. Here is what commit 6cea53e0 actually changed, read out of git:

-from odoo import http
+from odoo import _, http
+from odoo.exceptions import ValidationError

-    def xendit_payment(self, reference, token_ref, auth_id=None):
+    def xendit_payment(self, reference, token_ref, access_token, auth_id=None):
+        :param str access_token: The access token used to verify the provided values
+        if not payment_utils.check_access_token(access_token, reference):
+            raise ValidationError(_("The access token doesn't match the transaction reference."))

The detail I got wrong is the interesting one. The real check is check_access_token(access_token, reference), two arguments. The token is bound to the transaction reference and not to the amount.

Compare the sibling route in the same file, xendit_return, which does bind the amount:

if tx_sudo and payment_utils.check_access_token(access_token, tx_ref, tx_sudo.amount):

payment_utils.check_access_token takes *values and HMACs whatever it is handed, so the two call sites commit to different things. On the return route a token is only valid for that reference at that amount. On the payment route it is valid for the reference at any amount. Whether that matters depends on whether anything between token generation and the charge can move tx_sudo.amount, and on this route the token is generated in _get_specific_processing_values for the same transaction, so I am recording the asymmetry rather than claiming a bug. hoodie: assumes the amount cannot change between token issue and charge on this route; disproof is a flow that reissues or mutates the transaction amount while keeping the reference, then replays the earlier token.

The commit also patches generate_access_token in the tests, which is the part that keeps the fix honest: without a test that mints a real token the check could be weakened later and nothing would notice.

The vulnerable three-line version quoted earlier is the one running in the odoo:19.0 image I tested, which is where the draft -> error proof was captured. The report was accepted (final Medium 5.3, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N, after Odoo requalified integrity to low) and Odoo resolved it on 2026-06-23 in commit 6cea53e0. No CVE was assigned; Odoo handled it as a normal commit.

references