Merge pull request #1 from ndeet/satodri-18-fixes

Hardening the payment flow
This commit is contained in:
Sato DRI authored and GitHub committed 2026-08-07 16:47:43 +02:00
commit e11cbab174
1 file changed
+185 -23
+185 -23
View File
@@ -21,11 +21,10 @@
import json import json
import logging import logging
import pprint
from werkzeug import urls from werkzeug import urls
from odoo import http from odoo import _, http
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.http import request from odoo.http import request
@@ -47,6 +46,125 @@ class BTCPayController(http.Controller):
tokens={provider.btcpay_facade: provider.btcpay_token}, tokens={provider.btcpay_facade: provider.btcpay_token},
) )
@staticmethod
def _lock_transaction(tx_sudo):
"""Lock a transaction until the current database transaction ends.
Checkout and IPN requests can arrive concurrently. Serializing them on
the payment transaction prevents two checkout requests from creating
separate invoices and makes an IPN re-check the invoice ID after a
checkout replaces an expired invoice.
"""
tx_sudo.ensure_one()
tx_sudo.flush_recordset()
tx_sudo.env.cr.execute(
"SELECT id FROM payment_transaction WHERE id = %s FOR UPDATE",
[tx_sudo.id],
)
tx_sudo.invalidate_recordset([
'state', 'btcpay_invoiceId', 'provider_reference',
])
@staticmethod
def _ensure_invoice_id_is_unique(tx_sudo, invoice_id):
"""Reject an invoice ID already linked to another transaction."""
other_tx = tx_sudo.env['payment.transaction'].sudo().search([
('id', '!=', tx_sudo.id),
('provider_code', '=', 'btcpayserver'),
'|',
('btcpay_invoiceId', '=', invoice_id),
('provider_reference', '=', invoice_id),
], limit=1)
if other_tx:
raise ValidationError(_(
"BTCPay: The invoice is already linked to another transaction."
))
@classmethod
def _validate_invoice_binding(
cls, tx_sudo, incoming_invoice_id, incoming_order_id, fetched_invoice):
"""Bind the untrusted IPN payload to one authenticated invoice and transaction."""
if not isinstance(fetched_invoice, dict):
raise ValidationError(_("BTCPay: Received an invalid invoice response."))
fetched_invoice_id = fetched_invoice.get('id')
fetched_order_id = fetched_invoice.get('orderId')
if not (
incoming_invoice_id
and incoming_invoice_id == fetched_invoice_id
and incoming_invoice_id == tx_sudo.btcpay_invoiceId
and incoming_invoice_id == tx_sudo.provider_reference
):
raise ValidationError(_(
"BTCPay: The notification invoice does not match the transaction."
))
if not (
incoming_order_id
and incoming_order_id == fetched_order_id
and incoming_order_id == tx_sudo.reference
):
raise ValidationError(_(
"BTCPay: The notification order does not match the transaction."
))
cls._ensure_invoice_id_is_unique(tx_sudo, incoming_invoice_id)
@classmethod
def _find_transaction_by_invoice_id(cls, invoice_id):
"""Find exactly one BTCPay transaction from its stored invoice ID."""
if not isinstance(invoice_id, str) or not invoice_id:
raise ValidationError(_("BTCPay: Missing invoice ID in notification data."))
txs_sudo = request.env['payment.transaction'].sudo().search([
('provider_code', '=', 'btcpayserver'),
('btcpay_invoiceId', '=', invoice_id),
], limit=2)
if len(txs_sudo) != 1:
raise ValidationError(_(
"BTCPay: No unique transaction found matching the invoice ID."
))
return txs_sudo
@staticmethod
def _validate_invoice_amount(tx_sudo, invoice):
"""Reject reuse of an invoice with a different amount or currency."""
try:
amount = float(invoice.get('price'))
except (AttributeError, TypeError, ValueError):
amount = None
if (
amount is None
or invoice.get('currency') != tx_sudo.currency_id.name
or tx_sudo.currency_id.compare_amounts(amount, tx_sudo.amount) != 0
):
raise ValidationError(_(
"BTCPay: The existing invoice amount or currency does not match the transaction."
))
@classmethod
def _validate_existing_invoice(cls, tx_sudo, invoice_id, invoice):
"""Validate a stored invoice before redirecting the customer to it."""
cls._validate_invoice_binding(
tx_sudo, invoice_id, tx_sudo.reference, invoice,
)
if not isinstance(invoice.get('status'), str) or not invoice['status']:
raise ValidationError(_("BTCPay: The existing invoice response is incomplete."))
@classmethod
def _validate_created_invoice(cls, tx_sudo, invoice):
"""Validate a newly created invoice before storing its identity."""
if not isinstance(invoice, dict):
raise ValidationError(_("BTCPay: Received an invalid invoice response."))
invoice_id = invoice.get('id')
if (
not isinstance(invoice_id, str)
or not invoice_id
or not isinstance(invoice.get('url'), str)
or not invoice['url']
or invoice.get('orderId') not in (None, tx_sudo.reference)
):
raise ValidationError(_("BTCPay: The created invoice response is incomplete."))
cls._ensure_invoice_id_is_unique(tx_sudo, invoice_id)
@http.route(_checkout_url, type='http', auth='public', methods=['POST'], @http.route(_checkout_url, type='http', auth='public', methods=['POST'],
csrf=False, website=True) csrf=False, website=True)
def checkout(self, **data): def checkout(self, **data):
@@ -57,9 +175,10 @@ class BTCPayController(http.Controller):
server-side and are never taken from the (client-controlled) request server-side and are never taken from the (client-controlled) request
data, so a buyer cannot have an invoice created for a tampered amount. data, so a buyer cannot have an invoice created for a tampered amount.
""" """
_logger.info("BTCPay: checkout request with data:\n%s", pprint.pformat(data)) _logger.info("BTCPay: checkout request for transaction %s", data.get('reference'))
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'btcpayserver', data) 'btcpayserver', data)
self._lock_transaction(tx_sudo)
if tx_sudo.state != 'draft': if tx_sudo.state != 'draft':
# Already processed (e.g. double submission): fall back to the # Already processed (e.g. double submission): fall back to the
# generic payment status page. # generic payment status page.
@@ -68,6 +187,31 @@ class BTCPayController(http.Controller):
provider = tx_sudo.provider_id provider = tx_sudo.provider_id
base_url = provider.get_base_url() base_url = provider.get_base_url()
client = self._btcpay_client(provider) client = self._btcpay_client(provider)
# A browser retry must not create a second payable invoice. Reuse the
# stored invoice while it remains payable. If it expired, the new ID is
# stored before the lock is released, so late notifications for the old
# invoice can no longer select this transaction.
if tx_sudo.btcpay_invoiceId:
existing_invoice = client.get_invoice(tx_sudo.btcpay_invoiceId)
self._validate_existing_invoice(
tx_sudo, tx_sudo.btcpay_invoiceId, existing_invoice,
)
if existing_invoice['status'].lower() != 'expired':
self._validate_invoice_amount(tx_sudo, existing_invoice)
if (
not isinstance(existing_invoice.get('url'), str)
or not existing_invoice['url']
):
raise ValidationError(_(
"BTCPay: The existing invoice response is incomplete."
))
_logger.info(
"BTCPay: reusing invoice %s for transaction %s",
tx_sudo.btcpay_invoiceId, tx_sudo.reference,
)
return request.redirect(existing_invoice['url'], local=False)
invoice = client.create_invoice({ invoice = client.create_invoice({
"price": tx_sudo.amount, "price": tx_sudo.amount,
"currency": tx_sudo.currency_id.name, "currency": tx_sudo.currency_id.name,
@@ -86,36 +230,54 @@ class BTCPayController(http.Controller):
"notify": False, "notify": False,
}, },
}) })
tx_sudo.btcpay_invoiceId = invoice.get('id') self._validate_created_invoice(tx_sudo, invoice)
tx_sudo.write({
'btcpay_invoiceId': invoice['id'],
'provider_reference': invoice['id'],
})
_logger.info("BTCPay: created invoice %s for transaction %s", _logger.info("BTCPay: created invoice %s for transaction %s",
invoice.get('id'), tx_sudo.reference) invoice['id'], tx_sudo.reference)
return request.redirect(invoice['url'], local=False) return request.redirect(invoice['url'], local=False)
@http.route(_notify_url, type='json', auth='public', csrf=False) @http.route(_notify_url, type='json', auth='public', methods=['POST'], csrf=False)
def btcpay_ipn(self, **post): def btcpay_ipn(self, **post):
""" BTCPay IPN. """ """ BTCPay IPN. """
_logger.info('BTCPay: IPN received') _logger.info('BTCPay: IPN received')
data = json.loads(request.httprequest.data)
_logger.info("%s", pprint.pformat(data))
try: try:
notification_data = { data = json.loads(request.httprequest.data)
"reference": data['data']['orderId'], except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
"invoiceID": data['data']['id'], _logger.warning("BTCPay: received malformed JSON notification data")
} return ''
# Check the origin and integrity of the notification: the invoice
# is fetched back from BTCPay (signed request), the posted payload try:
# is only used to locate the transaction. ipn_data = data.get('data') if isinstance(data, dict) else None
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( if not isinstance(ipn_data, dict):
'btcpayserver', notification_data) raise ValidationError(_("BTCPay: Invalid notification data."))
incoming_invoice_id = ipn_data.get('id')
incoming_order_id = ipn_data.get('orderId')
# Never use the untrusted order ID to select a transaction. The
# stored invoice ID is the lookup key.
tx_sudo = self._find_transaction_by_invoice_id(incoming_invoice_id)
client = self._btcpay_client(tx_sudo.provider_id) client = self._btcpay_client(tx_sudo.provider_id)
fetched_invoice = client.get_invoice(notification_data['invoiceID']) # Fetching through the authenticated API establishes the invoice's
_logger.info('BTCPay: fetched invoice = %s', pprint.pformat(fetched_invoice)) # current state. Lock afterwards and re-check the stored ID so a
# concurrent checkout cannot replace it between lookup and use.
fetched_invoice = client.get_invoice(incoming_invoice_id)
self._lock_transaction(tx_sudo)
self._validate_invoice_binding(
tx_sudo, incoming_invoice_id, incoming_order_id, fetched_invoice,
)
_logger.info(
"BTCPay: verified invoice %s with status %s for transaction %s",
fetched_invoice.get('id'), fetched_invoice.get('status'), tx_sudo.reference,
)
notification_data = { notification_data = {
"reference": fetched_invoice.get('orderId'), "reference": tx_sudo.reference,
"status": fetched_invoice.get('status'), "status": fetched_invoice.get('status'),
"invoiceID": fetched_invoice.get('id'), "invoiceID": incoming_invoice_id,
"txid": fetched_invoice.get('url'), "txid": fetched_invoice.get('url'),
# Settled amount and currency, verified against the transaction # Settled amount and currency, verified against the transaction
# before it is set done. # before it is set done.
@@ -125,8 +287,8 @@ class BTCPayController(http.Controller):
# Handle the notification data # Handle the notification data
tx_sudo._handle_notification_data('btcpayserver', notification_data) tx_sudo._handle_notification_data('btcpayserver', notification_data)
except ValidationError: # Acknowledge the notification to avoid getting spammed except ValidationError as error: # Acknowledge rejected notifications without retries.
_logger.exception("Unable to handle the notification data; skipping to acknowledge") _logger.warning("BTCPay: rejected notification: %s", error)
return '' return ''
@http.route(_return_url, type='http', auth="public", methods=['GET'], @http.route(_return_url, type='http', auth="public", methods=['GET'],
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.