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.
This commit is contained in:
blackcoffeexbt 2026-08-20 08:21:24 +01:00 committed by GitHub
parent bf1977d0ac
commit 0e3246e6c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 77 additions and 2 deletions

View file

@ -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)

View file

@ -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/<hash>: 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):