Skip to content

fix(payments): budget claims per recipient, stop caching the 402 credential - #847

Merged
catomean merged 1 commit into
mainfrom
fix/audit-563-tail
Aug 29, 2026
Merged

fix(payments): budget claims per recipient, stop caching the 402 credential#847
catomean merged 1 commit into
mainfrom
fix/audit-563-tail

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Closes the tail of the #563 audit: finding 3 and suggestions 14, 15 and 17.

Finding 3 — a claim costs the recipient attention, so it is budgeted per recipient

acknowledgePublicPayment flips an intent to buyer_confirmed and fires a
"someone says they paid you" card into the seller's confirmation queue. One
card is a prompt to check a wallet; a hundred is a denial-of-attention attack
whose payoff is ship-the-goods-for-no-money.

The route in front of it only knows the caller's IP, and the abuse shape is
many addresses aimed at one seller — exactly what a per-IP budget cannot
see. rateLimitPaymentClaim(entityType, entityId) gives it a per-recipient
bound of 10/hour. Creating the intents was already bounded per recipient
(finding 1); this is the second half of the same fence.

Checked in the domain function rather than the route, so every caller is
covered — and only on the real transition: the idempotent re-claim paths return
before it, cost an attacker nothing, and so must not consume a genuine payer's
allowance either.

Suggestion 15 — a 402 carried a bearer credential with no Cache-Control

The 402 body embeds token (<intentId>.<statusToken>) and WWW-Authenticate
repeats it. That token is what a payer later exchanges for a receipt, so
anything that cached the response cached a credential for someone else's
payment. Every other payment response sets no-store; apiPaymentRequired was
the one that did not. It does now, sharing the same CACHE_PRESETS.NONE string
so it cannot drift from the convention.

Suggestion 14 — signatures advertising authorization that no longer exists

refreshPaymentStatus, reconcilePaymentIntent and resolveSellerWallet each
took a supabase client they had stopped using — every one calls
getAdminClient() internally. A caller could reasonably pass a user-scoped
client and believe RLS was being enforced. Parameter removed from all three.

Removing it from resolveSellerWallet made resolveSellerReceiveInfo's client
dead in the same way, so that one goes too rather than leaving a fresh instance
of the smell behind. buyerConfirmPayment genuinely uses its client and is
untouched — which is why the audit did not list it.

Suggestion 17 — the two real test gaps

The embeddings floor was never executed. Every forget test disables
embeddings, so the semantic fallback and the 0.45 floor it passes to
match_cat_memories had no coverage at all. A silent revert to the old 0.75
(near-identical-text territory, which never fired for real phrasings) would
have changed which memories a user's "forget that" reaches, and nothing would
have failed. Now pinned, along with the fallback ordering (a word match must
never reach the RPC) and its failure behaviour.

Stem-sharing innocents. INCIDENT_CORPUS was built from one real
over-deletion, so every row is either a target or obviously unrelated — a shape
that flatters the matcher. Added a corpus where innocents share a stem with the
target ("cooking skills" beside "photography skills"), proving the
word-boundary fix from finding 9 holds against them.

One of those tests documents rather than changes behaviour, and is worth
flagging: a single generic word is treated as a topic, so "skills" clears
every memory phrased with it across unrelated subjects. That is the deliberate
single-word containment rule (memory-forget.test.ts:138 — "this is how a user
removes a whole topic"), not an over-match, and #838 makes it visible by naming
every deleted memory in the reply. Pinned so it stays a decision rather than a
surprise.

The audit's third 17 item — no handler-level test for forget_memories — was
closed by #838.

Verification

Full non-incremental type-check clean; __tests__/unit/domain/payments,
__tests__/unit/cat and __tests__/unit/api green. Findings 6 and 17(a) were
verified already-closed in main rather than re-done.

Refs #563 finding 3, suggestions 14, 15, 17.

…ential

Closes the tail of the #563 audit.

FINDING 3 — an acknowledge flips an intent to buyer_confirmed and fires a
"someone says they paid you" card into the seller's queue. One card is a
prompt to check a wallet; a hundred is a denial-of-attention attack whose
payoff is ship-the-goods-for-no-money. The budget in front of it was keyed on
the caller, and the abuse shape is many addresses aimed at ONE seller.

rateLimitPaymentClaim bounds it at 10/hour per recipient, asked once on the
real transition — the idempotent re-claim paths return before it, cost an
attacker nothing, and so must not spend a genuine payer's allowance either.

The guard is INJECTED, not imported. Importing the limiter into the payments
domain dragged Upstash's ESM build into eight domain test suites that have no
business knowing about Redis — the layering violation announced itself. The
HTTP layer owns the policy; the domain owns when it is asked.

SUGGESTION 15 — the 402 body embeds `token` and WWW-Authenticate repeats it.
That token is what a payer later exchanges for a receipt, so anything caching
the response cached a credential for someone else's payment. Every other
payment response set no-store; this one did not.

SUGGESTION 14 — refreshPaymentStatus, reconcilePaymentIntent and
resolveSellerWallet each took a supabase client they had stopped using; all
three call getAdminClient() internally, so the signature advertised
caller-scoped RLS behaviour that no longer existed. Removing it from
resolveSellerWallet made resolveSellerReceiveInfo's client dead the same way,
so that goes too. buyerConfirmPayment genuinely uses its client and is
untouched — which is why the audit did not list it.

SUGGESTION 17 — the semantic fallback and its 0.45 floor had no coverage at
all: every forget test disables embeddings, so a silent revert to the old 0.75
would have changed which memories "forget that" reaches with nothing failing.
Added a stem-sharing-innocents corpus too, since INCIDENT_CORPUS holds only
targets and obvious strangers — a shape that flatters the matcher.

One of those tests documents rather than changes behaviour: a single generic
word is treated as a TOPIC, so "skills" clears every memory phrased with it.
That is the deliberate containment rule (memory-forget.test.ts:138), not an
over-match, and #838 makes it visible by naming each deleted memory.

Refs #563 finding 3, suggestions 14, 15, 17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018waGt1ieA9TjpscqrbrnGb
@catomean
catomean merged commit 25d78ad into main Aug 29, 2026
6 checks passed
@catomean
catomean deleted the fix/audit-563-tail branch August 29, 2026 10:53
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