mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-20 13:37:35 +02:00
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:
parent
bf1977d0ac
commit
0e3246e6c0
2 changed files with 77 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue