While wiring the ai-config settings UI, found the same "trust a
client-supplied pubkey" pattern repeated across every payment-moving
route in the app — not an isolated bug. Fixed all of them:
- GET /api/payments/winnings/:botId (CRITICAL): had NO auth check at
all AND returned the raw, spendable Cashu bearer token in the list
response. botId is public (every fight/profile URL), so anyone could
list any bot's unclaimed winnings and get the live token back before
the real winner claimed it — direct fund theft, zero auth required.
Fixed: require JWT-derived ownership of botId; the list endpoint no
longer returns the token at all (only paymentId + amountSats) — the
token is now only ever revealed once, via the explicit claim below.
- POST /api/payments/connect-wallet (CRITICAL): trusted a
client-supplied pubkey with NO ownership check whatsoever. Anyone
could attach an attacker-controlled NWC connection string or
Lightning Address to ANY victim bot by pubkey, silently redirecting
all future fight-winnings payouts to the attacker's wallet.
- POST /api/payments/claim/:paymentId: same pubkey-trust pattern,
hands back a live spendable Cashu token — the single most sensitive
check in the file.
- DELETE /api/payments/disconnect-wallet: trusted pubkey with no
ownership check (DoS: anyone could kill a victim's payout wallet).
- POST /api/queue/join-ranked/:botId: compared a client-supplied
pubkey directly against bot.publicKey with no signature/JWT
verification. pubkeys are public by design in nostr (shown on every
bot's own profile page), so this was not an ownership check at all.
Also hardened (lower severity, same fix for consistency):
POST /create-invoice, POST /confirm/:paymentId, GET /wallet-status.
Fix pattern, consistent with auth.ts (f5f57e6): pubkey is now always
derived from extractPubkeyFromAuth(Authorization: Bearer <jwt>), never
trusted from a request body or query string. Added a shared
verifyBotOwner() helper in bot-auth.ts for the dual-audience routes
(nostr-signed-in owners AND anonymous poll-mode bots via
Authorization: Bot <id>:<secret>). Schemas (connectWalletSchema,
createInvoiceSchema, joinRankedSchema, disconnectWalletSchema) no
longer declare a pubkey field — removing the field is itself a guard
against the pattern regressing. Frontend callers already used
authFetch (attaches the Bearer JWT automatically) for every one of
these, so no behavior change for legitimate callers — only closes the
hole for illegitimate ones.
Root-caused test failures this surfaced: a leaked mockReturnValueOnce
queue value cascaded through payments.test.ts once earlier tests
started 401-ing before consuming their queued mock (disconnect-wallet
-> zap Attack3 -> Attack4 -> claim Attack7). Fixed by giving each
newly-auth-gated test a real JWT (createJwt, not mocked) instead of
loosening the auth requirement.
Full server suite: 806-815/810-829 passing depending on run (only
pre-existing CPU-load-sensitive timing/throughput benchmarks flake,
all confirmed passing in isolation and confirmed untouched by this
diff — bot-auth.ts constant-time variance, lifecycle.ts fight
throughput, shutdown.ts timeout, fights.mock dev-check). tsc --noEmit
clean (server + frontend).
Co-Authored-By: Claude <noreply@anthropic.com>