Skip to content

fix(payments): a seller's own wallet could be used to get them banned - #819

Merged
catomean merged 1 commit into
mainfrom
fix/recipient-invoice-spam-limit
Aug 28, 2026
Merged

fix(payments): a seller's own wallet could be used to get them banned#819
catomean merged 1 commit into
mainfrom
fix/recipient-invoice-spam-limit

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Closes finding 1 of #563.

GET /api/v1/pay/... and POST /api/v1/payments/public each mint a real Lightning invoice through the recipient's own LNURL/NWC relay, on every request. Both were limited per IP and nothing else.

So an attacker rotating addresses could drive unbounded invoice creation against one seller — getting them rate-limited or banned by their own wallet provider, and littering their queue with orphan intents. The victim is the person who did nothing.

Per-IP answers "is one caller hammering us". It cannot answer the question that actually protects a seller: "is one recipient's wallet being hammered", however many addresses it arrives from.

The tool already existed

rateLimitTipRecipient — 20 invoices per 5 minutes against any one recipient — and its own doc comment says to apply it in addition to the per-IP limit. It was wired to tips and lnurlp only. rateLimitPaymentRecipient addresses an entity rather than a username and shares that budget, because it's the same victim's wallet either way.

Ordering differs per route, deliberately

  • L402 challenge — recipient is in the path, so the check sits beside the per-IP one.
  • public-support — recipient is only known after the body parses, so it sits after parsing and before initiatePublicSupport, which is the call that actually mints the invoice.

The verify-with-proof branch is left alone: it creates no intent. Finding 7 covers its outbound cost separately.

Residual, stated rather than hidden

A profile reachable both by username and by entity id has two buckets, so an attacker splitting across both paths gets twice the budget. Collapsing them needs a username→entity lookup on the rate-limit path — a database round trip before deciding whether to serve at all. Twice a bounded number is still bounded; unbounded was the bug.

Verification

type-check green, check:client-ip green, 20 tests across both route suites.

Mutation-proven: removing the guard fails exactly the two new tests and nothing else; restoring it passes all 12.

Unblocked by #816 — until the limiter key stopped being caller-controlled, no limiter on these routes could be tripped at all.

GET /api/v1/pay/... and POST /api/v1/payments/public each mint a REAL Lightning
invoice through the RECIPIENT's own LNURL/NWC relay, on every request. Both were
limited per IP and nothing else, so an attacker rotating addresses could drive
unbounded invoice creation against one seller — getting them rate-limited or
banned by their own wallet provider, and littering their queue with orphan
intents. The victim is the person who did nothing.

Per-IP answers "is one caller hammering us". It cannot answer the question that
protects a seller: "is one RECIPIENT's wallet being hammered", however many
addresses it arrives from.

rateLimitTipRecipient was built for exactly this — 20 invoices per 5 minutes
against any one recipient — and its own doc says to apply it IN ADDITION to the
per-IP limit. It was wired to tips and lnurlp only. rateLimitPaymentRecipient
addresses an entity rather than a username and shares that budget, because it is
the same victim's wallet either way.

Ordering matters and differs per route: the L402 challenge has the recipient in
its path, so the check sits beside the per-IP one; the public-support route only
learns the recipient after the body parses, so it sits after parsing and BEFORE
initiatePublicSupport, which is the call that actually mints the invoice.

The verify-with-proof branch is deliberately left alone — it creates no intent
(finding 7 covers its outbound cost separately).

Residual, stated rather than hidden: a profile reachable both by username and by
entity id has two buckets, so splitting across both paths doubles the budget.
Collapsing them needs a username->entity lookup on the rate-limit path, a
database round trip before deciding whether to serve at all. Twice a bounded
number is still bounded; unbounded was the bug.

#563 finding 1. Unblocked by #816 — until the key stopped
being caller-controlled, no limiter here could be tripped at all.

Mutation-proven: removing the guard fails exactly the two new tests and nothing
else; restoring it passes all 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
@catomean
catomean merged commit 945f101 into main Aug 28, 2026
6 checks passed
@catomean
catomean deleted the fix/recipient-invoice-spam-limit branch August 28, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant