From 0e3246e6c037f68b5bbe0cd571bbd03c08478fcd Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:21:24 +0100 Subject: [PATCH] Merge commit from fork The public payment status endpoint returned the stored preimage in the pending-payment fall-through, leaking valid preimages of unpaid invoices to unauthenticated callers on funding sources that generate the preimage at invoice creation (FakeWallet, CoreLightning, CLNRest, LndRest, LndGrpc, Eclair). Only expose the preimage once the payment is actually successful and scrub it from details in the exception branch. Adds a regression test covering unauthenticated, invalid-key, non-owning-key, expired-invoice and paid-control cases. --- lnbits/core/views/payment_api.py | 11 +++++- tests/api/test_api.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index 5239c7454..7e92ea078 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -405,6 +405,8 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)): payment = await update_pending_payment(payment) except Exception: if wallet and wallet.id == payment.wallet_id: + # do not expose the preimage of unpaid payments + payment.preimage = None return {"paid": False, "details": payment} return {"paid": False} @@ -412,10 +414,15 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)): return { "paid": payment.success, "status": f"{payment.status!s}", - "preimage": payment.preimage, + # only expose the preimage once the payment is actually successful + "preimage": payment.preimage if payment.success else None, "details": payment, } - return {"paid": payment.success, "preimage": payment.preimage} + return { + "paid": payment.success, + # only expose the preimage once the payment is actually successful + "preimage": payment.preimage if payment.success else None, + } @payment_router.post("/decode", status_code=HTTPStatus.OK) diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 2401c71fe..9771b87a1 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -1,3 +1,4 @@ +import asyncio import hashlib from json import JSONDecodeError from unittest.mock import AsyncMock, Mock @@ -371,6 +372,73 @@ async def test_check_payment_with_key(client, invoice: Payment, inkey_headers_fr assert "details" in response.json() +# check GET /api/v1/payments/: preimage of an unpaid invoice must not leak +@pytest.mark.anyio +async def test_check_pending_payment_does_not_expose_preimage( + client, inkey_headers_from, inkey_headers_to, adminkey_headers_from +): + # create an unpaid invoice (FakeWallet stores a valid preimage at creation) + data = await get_random_invoice_data() + response = await client.post("/api/v1/payments", json=data, headers=inkey_headers_to) + assert response.status_code == 201 + unpaid = response.json() + payment_hash = unpaid["payment_hash"] + + # unauthenticated request must not return the preimage + response = await client.get(f"/api/v1/payments/{payment_hash}") + assert response.status_code < 300 + assert response.json()["paid"] is False + assert response.json()["preimage"] is None + + # same for an invalid key + response = await client.get( + f"/api/v1/payments/{payment_hash}", headers={"X-Api-Key": "invalid_key"} + ) + assert response.json()["paid"] is False + assert response.json()["preimage"] is None + + # a valid key of a different (non-owning) wallet scopes the lookup + # to that wallet and therefore yields 404, leaking nothing at all + response = await client.get( + f"/api/v1/payments/{payment_hash}", headers=inkey_headers_from + ) + assert response.status_code == 404 + + # expired unpaid invoices must not leak the preimage either + expiry_data = await get_random_invoice_data() + expiry_data["expiry"] = 1 + response = await client.post( + "/api/v1/payments", json=expiry_data, headers=inkey_headers_to + ) + assert response.status_code == 201 + expired_hash = response.json()["payment_hash"] + await asyncio.sleep(2) + response = await client.get(f"/api/v1/payments/{expired_hash}") + assert response.json()["paid"] is False + assert response.json()["preimage"] is None + + # after payment the preimage is exposed again as proof of payment + response = await client.post( + "/api/v1/payments", + json={"out": True, "bolt11": unpaid["bolt11"]}, + headers=adminkey_headers_from, + ) + assert response.status_code < 300 + # internal payments settle asynchronously, give the listener a moment + preimage = None + paid = False + for _ in range(10): + response = await client.get(f"/api/v1/payments/{payment_hash}") + paid = response.json()["paid"] + preimage = response.json()["preimage"] + if paid: + break + await asyncio.sleep(0.5) + assert paid is True + assert preimage is not None + assert hashlib.sha256(bytes.fromhex(preimage)).hexdigest() == payment_hash + + # check POST /api/v1/payments: payment with wrong key type @pytest.mark.anyio async def test_pay_invoice_wrong_key(client, invoice, adminkey_headers_from):