fix(security): five more IDOR/missing-auth bugs in payments + queue (same class as f5f57e6)

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>
This commit is contained in:
Dorian
2026-07-31 13:00:08 -04:00
co-authored by Claude
parent f5f57e60d9
commit c162d5ebe9
7 changed files with 164 additions and 64 deletions
+3 -7
View File
@@ -64,7 +64,6 @@ export function useWallet() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
pubkey: pubkey.value,
method: 'nwc', method: 'nwc',
connectionData: connectionString, connectionData: connectionString,
}), }),
@@ -93,7 +92,6 @@ export function useWallet() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
pubkey: pubkey.value,
method: 'lnaddress', method: 'lnaddress',
connectionData: address, connectionData: address,
}), }),
@@ -114,8 +112,6 @@ export function useWallet() {
await authFetch('/api/payments/disconnect-wallet', { await authFetch('/api/payments/disconnect-wallet', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value }),
}) })
walletMethod.value = null walletMethod.value = null
@@ -129,7 +125,7 @@ export function useWallet() {
async function checkWalletStatus(): Promise<void> { async function checkWalletStatus(): Promise<void> {
if (!pubkey.value) return if (!pubkey.value) return
const res = await authFetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`) const res = await authFetch('/api/payments/wallet-status')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
isWalletConnected.value = data.connected isWalletConnected.value = data.connected
@@ -148,7 +144,7 @@ export function useWallet() {
const invoiceRes = await authFetch('/api/payments/create-invoice', { const invoiceRes = await authFetch('/api/payments/create-invoice', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId, pubkey: pubkey.value }), body: JSON.stringify({ botId }),
}) })
if (!invoiceRes.ok) { if (!invoiceRes.ok) {
@@ -179,7 +175,7 @@ export function useWallet() {
await authFetch(`/api/payments/confirm/${paymentId}`, { await authFetch(`/api/payments/confirm/${paymentId}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preimage, pubkey: pubkey.value }), body: JSON.stringify({ preimage }),
}) })
paymentStatus.value = 'confirmed' paymentStatus.value = 'confirmed'
return paymentId return paymentId
+3 -1
View File
@@ -717,10 +717,12 @@ async function fightRanked() {
// don't create a duplicate Lightning invoice via payEntryFee(). // don't create a duplicate Lightning invoice via payEntryFee().
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id) const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
cashuPaymentId.value = null cashuPaymentId.value = null
// Ownership is verified server-side from the Bearer JWT that authFetch
// attaches automatically — no client-supplied pubkey needed (or trusted).
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, { const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId, pubkey: pubkey.value }), body: JSON.stringify({ paymentId }),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
+7 -6
View File
@@ -88,14 +88,15 @@ export const withdrawSchema = z.object({
// --- Payment schemas --- // --- Payment schemas ---
export const connectWalletSchema = z.object({ export const connectWalletSchema = z.object({
pubkey: pubkeySchema,
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']), method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
connectionData: z.string().min(1), connectionData: z.string().min(1),
}) })
// pubkey is intentionally NOT part of this schema — see connect-wallet /
// create-invoice / claim / disconnect-wallet in payments.ts, which all
// derive ownership from the verified JWT, never a client-supplied field.
export const createInvoiceSchema = z.object({ export const createInvoiceSchema = z.object({
botId: idSchema, botId: idSchema,
pubkey: pubkeySchema.optional(),
}) })
export const submitCashuSchema = z.object({ export const submitCashuSchema = z.object({
@@ -109,9 +110,7 @@ export const zapSchema = z.object({
amountSats: satsSchema, amountSats: satsSchema,
}) })
export const disconnectWalletSchema = z.object({ export const disconnectWalletSchema = z.object({})
pubkey: pubkeySchema,
})
// --- Tournament schemas --- // --- Tournament schemas ---
@@ -134,9 +133,11 @@ export const startTournamentSchema = z.object({
// --- Queue schemas --- // --- Queue schemas ---
// pubkey is intentionally NOT part of this schema — ownership is verified
// server-side via verifyBotOwner (JWT-derived pubkey or bot-secret), never
// from a client-supplied field. See queue.ts.
export const joinRankedSchema = z.object({ export const joinRankedSchema = z.object({
paymentId: idSchema, paymentId: idSchema,
pubkey: pubkeySchema.optional(),
}) })
// --- Docs schemas --- // --- Docs schemas ---
+38
View File
@@ -6,6 +6,7 @@ import { createHash, timingSafeEqual } from 'crypto'
import { db, schema } from '../db/index.js' import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { Context } from 'hono' import type { Context } from 'hono'
import { extractPubkeyFromAuth } from './jwt.js'
export interface BotAuthContext { export interface BotAuthContext {
botId: string botId: string
@@ -68,3 +69,40 @@ export async function authenticateBot(c: Context): Promise<BotAuthContext | Resp
webhookUrl: rows[0].webhookUrl, webhookUrl: rows[0].webhookUrl,
} }
} }
/**
* Verify the caller owns `botId`, for routes that must accept BOTH audiences:
* nostr-signed-in owners (web UI's JWT session) and anonymous poll-mode bots
* (Authorization: Bot <id>:<secret>, which never have a publicKey — see
* BOTFIGHTS.md). This is the ONLY correct way to check the nostr side: it
* derives pubkey from a verified JWT (extractPubkeyFromAuth), never from a
* client-supplied `pubkey` field. A bare `body.pubkey === bot.publicKey`
* comparison is not an ownership check at all — pubkeys are public by
* design in nostr (shown on every bot's own profile page), so anyone who's
* viewed a bot's page could pass that same auth-check with zero secret
* material. (This exact bug, at POST /api/auth/update, was found and fixed
* in 09-06 — see auth.ts. Same class, same fix, applied everywhere ownership
* is checked by pubkey.)
*/
export async function verifyBotOwner(c: Context, botId: string): Promise<true | Response> {
const auth = c.req.header('Authorization')
if (auth?.startsWith('Bearer ')) {
const pubkey = extractPubkeyFromAuth(auth)
if (!pubkey) {
return c.json({ error: 'Invalid or expired session.' }, 401)
}
const rows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (rows.length === 0 || rows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
return true
}
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
if (botOrRes.botId !== botId) {
return c.json({ error: 'Unauthorized' }, 403)
}
return true
}
+28 -10
View File
@@ -56,6 +56,16 @@ vi.mock('../middleware/rate-limit.js', () => ({
const { paymentsRouter } = await import('./payments.js') const { paymentsRouter } = await import('./payments.js')
const { db, schema } = await import('../db/index.js') const { db, schema } = await import('../db/index.js')
const { createEntryInvoice, checkPaymentStatus } = await import('../engine/payments.js') const { createEntryInvoice, checkPaymentStatus } = await import('../engine/payments.js')
// jwt.js is intentionally NOT mocked — connect-wallet, disconnect-wallet,
// wallet-status, winnings, and claim all derive identity from a real,
// verified JWT (see 09-06 IDOR fix), so tests that exercise the
// authenticated path need a real token, not a stubbed one.
const { createJwt } = await import('../middleware/jwt.js')
const TEST_PUBKEY = 'a'.repeat(64)
function authHeader(pubkey = TEST_PUBKEY) {
return { Authorization: `Bearer ${createJwt(pubkey)}` }
}
function makeApp() { function makeApp() {
const app = new Hono() const app = new Hono()
@@ -68,12 +78,22 @@ describe('payments routes', () => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('connect-wallet returns 400 when missing fields', async () => { it('connect-wallet returns 401 with no Authorization header', async () => {
const app = makeApp() const app = makeApp()
const res = await app.request('/api/payments/connect-wallet', { const res = await app.request('/api/payments/connect-wallet', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: 'abc' }), body: JSON.stringify({ method: 'nwc', connectionData: 'x' }),
})
expect(res.status).toBe(401)
})
it('connect-wallet returns 400 when missing fields', async () => {
const app = makeApp()
const res = await app.request('/api/payments/connect-wallet', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({}),
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
const json = await res.json() as { error: string } const json = await res.json() as { error: string }
@@ -85,9 +105,8 @@ describe('payments routes', () => {
// db.select will return empty array by default // db.select will return empty array by default
const res = await app.request('/api/payments/connect-wallet', { const res = await app.request('/api/payments/connect-wallet', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ body: JSON.stringify({
pubkey: 'a'.repeat(64),
method: 'nwc', method: 'nwc',
connectionData: 'nostr+walletconnect://test', connectionData: 'nostr+walletconnect://test',
}), }),
@@ -160,14 +179,14 @@ describe('payments routes', () => {
expect(json.error).toContain('Missing') expect(json.error).toContain('Missing')
}) })
it('disconnect-wallet returns 400 when missing pubkey', async () => { it('disconnect-wallet returns 401 with no Authorization header', async () => {
const app = makeApp() const app = makeApp()
const res = await app.request('/api/payments/disconnect-wallet', { const res = await app.request('/api/payments/disconnect-wallet', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), body: JSON.stringify({}),
}) })
expect(res.status).toBe(400) expect(res.status).toBe(401)
}) })
it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => { it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => {
@@ -184,8 +203,8 @@ describe('payments routes', () => {
const app = makeApp() const app = makeApp()
const res = await app.request('/api/payments/disconnect-wallet', { const res = await app.request('/api/payments/disconnect-wallet', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ pubkey: 'a'.repeat(64) }), body: JSON.stringify({}),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const json = await res.json() as { success: boolean } const json = await res.json() as { success: boolean }
@@ -314,9 +333,8 @@ describe('payment security — attack vectors', () => {
const app = makeApp() const app = makeApp()
const res = await app.request('/api/payments/connect-wallet', { const res = await app.request('/api/payments/connect-wallet', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ body: JSON.stringify({
pubkey: 'a'.repeat(64),
method: 'paypal', method: 'paypal',
connectionData: 'malicious://data', connectionData: 'malicious://data',
}), }),
+73 -22
View File
@@ -6,17 +6,32 @@ import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js' import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { encrypt, decrypt } from '../engine/crypto.js' import { encrypt, decrypt } from '../engine/crypto.js'
import { rateLimit } from '../middleware/rate-limit.js' import { rateLimit } from '../middleware/rate-limit.js'
import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError, sanitizeError } from '../lib/validators.js' import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, zapSchema, formatZodError, sanitizeError } from '../lib/validators.js'
import { extractPubkeyFromAuth } from '../middleware/jwt.js'
export const paymentsRouter = new Hono() export const paymentsRouter = new Hono()
// POST /connect-wallet // POST /connect-wallet
//
// SECURITY: pubkey MUST come from the verified JWT, never the request body.
// This handler previously trusted a client-supplied `pubkey` field with NO
// ownership check at all — any unauthenticated caller could attach an
// attacker-controlled NWC connection string or Lightning Address to ANY
// victim bot by pubkey (public by design in nostr), silently redirecting
// all of that bot's future fight-winnings payouts to the attacker's own
// wallet. Direct fund theft, not just profile hijacking. Found and fixed
// alongside the identical pattern at POST /api/auth/update (09-06).
paymentsRouter.post('/connect-wallet', async (c) => { paymentsRouter.post('/connect-wallet', async (c) => {
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!pubkey) {
return c.json({ error: 'Authentication required.' }, 401)
}
const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({}))) const parsed = connectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey, method, or connectionData') }, 400) return c.json({ error: formatZodError(parsed.error, {}, 'Missing method or connectionData') }, 400)
} }
const { pubkey, method, connectionData } = parsed.data const { method, connectionData } = parsed.data
// Look up bot by publicKey // Look up bot by publicKey
const botRows = await db.select({ id: schema.bots.id }) const botRows = await db.select({ id: schema.bots.id })
@@ -59,9 +74,11 @@ paymentsRouter.post('/connect-wallet', async (c) => {
return c.json({ success: true }) return c.json({ success: true })
}) })
// GET /wallet-status // GET /wallet-status — read-only, but still derives identity from the JWT
// rather than a query-string pubkey, so this can't be used to enumerate
// whether an arbitrary victim pubkey has a wallet connected.
paymentsRouter.get('/wallet-status', async (c) => { paymentsRouter.get('/wallet-status', async (c) => {
const pubkey = c.req.query('pubkey') const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!pubkey) return c.json({ connected: false, method: null }) if (!pubkey) return c.json({ connected: false, method: null })
const botRows = await db.select({ id: schema.bots.id }) const botRows = await db.select({ id: schema.bots.id })
@@ -85,12 +102,14 @@ paymentsRouter.get('/wallet-status', async (c) => {
paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => { paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => {
const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({}))) const parsed = createInvoiceSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { botId: 'Missing botId' }, 'Missing botId') }, 400) if (!parsed.success) return c.json({ error: formatZodError(parsed.error, { botId: 'Missing botId' }, 'Missing botId') }, 400)
const { botId, pubkey } = parsed.data const { botId } = parsed.data
// In production, verify bot ownership // In production, verify bot ownership via the verified JWT — never a
// client-supplied pubkey field (same fix class as connect-wallet above).
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) { const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
return c.json({ error: 'Missing pubkey' }, 400) if (!pubkey) {
return c.json({ error: 'Authentication required.' }, 401)
} }
const botRows = await db.select({ publicKey: schema.bots.publicKey }) const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
@@ -130,7 +149,7 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
return c.json({ error: 'Invalid paymentId' }, 400) return c.json({ error: 'Invalid paymentId' }, 400)
} }
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined })) const { preimage } = await c.req.json<{ preimage?: string }>().catch(() => ({ preimage: undefined }))
const rows = await db.select().from(schema.payments) const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId)).limit(1) .where(eq(schema.payments.id, paymentId)).limit(1)
@@ -143,15 +162,19 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
// Must be an inbound entry payment // Must be an inbound entry payment
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400) if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
// Verify caller owns this payment's bot // Verify caller owns this payment's bot — pubkey comes from the verified
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) { // JWT, never a client-supplied field (same fix class as connect-wallet
// above: a bare body.pubkey === bot.publicKey check is not an ownership
// proof, since pubkeys are public by design in nostr).
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (pubkey) {
const botRows = await db.select({ publicKey: schema.bots.publicKey }) const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) { if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403) return c.json({ error: 'Unauthorized' }, 403)
} }
} else if (process.env.NODE_ENV === 'production') { } else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing or invalid pubkey' }, 400) return c.json({ error: 'Authentication required.' }, 401)
} }
// In production, also verify payment via NWC lookup (belt and suspenders) // In production, also verify payment via NWC lookup (belt and suspenders)
@@ -204,18 +227,42 @@ paymentsRouter.post('/submit-cashu', async (c) => {
}) })
// GET /winnings/:botId // GET /winnings/:botId
//
// SECURITY (critical): this handler previously had NO auth check at all AND
// returned the raw, spendable Cashu bearer token in the list response.
// botId is public (appears in every fight/profile URL), so anyone could
// list ANY bot's unclaimed winnings and get the live token back —
// no ownership proof needed whatsoever. Whoever holds a Cashu token can
// redeem it, so this leaked real, spendable sats to any caller who beat the
// legitimate winner to the request. Fixed: require JWT-derived ownership of
// botId, and never include the token itself in the list — only reveal it
// via the explicit POST /claim/:paymentId below, which also clears it from
// storage (single-use reveal, correct claim semantics).
paymentsRouter.get('/winnings/:botId', async (c) => { paymentsRouter.get('/winnings/:botId', async (c) => {
const botId = c.req.param('botId') const botId = c.req.param('botId')
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!pubkey) {
return c.json({ error: 'Authentication required.' }, 401)
}
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
const unclaimed = await db.select({ const unclaimed = await db.select({
paymentId: schema.payments.id, paymentId: schema.payments.id,
cashuToken: schema.payments.cashuToken, hasToken: schema.payments.cashuToken,
amountSats: schema.payments.amountSats, amountSats: schema.payments.amountSats,
}).from(schema.payments) }).from(schema.payments)
.where(eq(schema.payments.botId, botId)) .where(eq(schema.payments.botId, botId))
// Filter in JS since drizzle doesn't easily combine multiple conditions // Filter in JS since drizzle doesn't easily combine multiple conditions.
const filtered = unclaimed.filter(p => p.cashuToken) // Never include the raw token here — see comment above.
const filtered = unclaimed
.filter(p => p.hasToken)
.map(p => ({ paymentId: p.paymentId, amountSats: p.amountSats }))
return c.json({ unclaimed: filtered }) return c.json({ unclaimed: filtered })
}) })
@@ -223,7 +270,6 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
// POST /claim/:paymentId // POST /claim/:paymentId
paymentsRouter.post('/claim/:paymentId', async (c) => { paymentsRouter.post('/claim/:paymentId', async (c) => {
const paymentId = c.req.param('paymentId') const paymentId = c.req.param('paymentId')
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
const rows = await db.select().from(schema.payments) const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId)) .where(eq(schema.payments.id, paymentId))
@@ -233,7 +279,11 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
const payment = rows[0] const payment = rows[0]
// Verify caller owns this payment's bot // Verify caller owns this payment's bot — pubkey comes from the verified
// JWT, never a client-supplied field. See GET /winnings above for the
// severity rationale (this route hands back a live, spendable bearer
// token — the single most sensitive check in this file).
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (pubkey) { if (pubkey) {
const botRows = await db.select({ publicKey: schema.bots.publicKey }) const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1) .from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
@@ -241,7 +291,7 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
return c.json({ error: 'Unauthorized' }, 403) return c.json({ error: 'Unauthorized' }, 403)
} }
} else if (process.env.NODE_ENV === 'production') { } else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing pubkey' }, 400) return c.json({ error: 'Authentication required.' }, 401)
} }
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400) if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
@@ -255,9 +305,10 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
// DELETE /disconnect-wallet // DELETE /disconnect-wallet
paymentsRouter.delete('/disconnect-wallet', async (c) => { paymentsRouter.delete('/disconnect-wallet', async (c) => {
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({}))) const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey') }, 400) if (!pubkey) {
const { pubkey } = parsed.data return c.json({ error: 'Authentication required.' }, 401)
}
const botRows = await db.select({ id: schema.bots.id }) const botRows = await db.select({ id: schema.bots.id })
.from(schema.bots) .from(schema.bots)
+12 -18
View File
@@ -5,7 +5,7 @@ import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
import { rateLimit } from '../middleware/rate-limit.js' import { rateLimit } from '../middleware/rate-limit.js'
import { joinRankedSchema, sanitizeError } from '../lib/validators.js' import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
import { authenticateBot } from '../middleware/bot-auth.js' import { verifyBotOwner } from '../middleware/bot-auth.js'
export const queueRouter = new Hono() export const queueRouter = new Hono()
@@ -57,7 +57,14 @@ queueRouter.get('/ranked-status', (c) => {
// Join ranked queue — requires confirmed payment + bot ownership. // Join ranked queue — requires confirmed payment + bot ownership.
// Ownership can be proven either way, since ranked/staked fights are for // Ownership can be proven either way, since ranked/staked fights are for
// BOTH audiences (not just nostr-signed-in humans): // BOTH audiences (not just nostr-signed-in humans):
// 1. pubkey (nostr-authenticated bots, the web UI's own JWT session flow) // 1. Authorization: Bearer <jwt> (nostr-authenticated bots, the web UI's
// own JWT session flow) — verified via verifyBotOwner, which derives
// pubkey from the JWT itself, never from a client-supplied field. A
// bare `body.pubkey === bot.publicKey` comparison (the previous
// implementation here) is not an ownership check: pubkeys are public
// by design in nostr, shown on every bot's own profile page, so it let
// anyone who'd seen a bot's page join ranked queue as that bot. Found
// and fixed alongside the identical bug at POST /api/auth/update (09-06).
// 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the // 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the
// primary registration path for AI agents per BOTFIGHTS.md, which // primary registration path for AI agents per BOTFIGHTS.md, which
// never have a publicKey at all: confirmed live, publicKey is null // never have a publicKey at all: confirmed live, publicKey is null
@@ -69,25 +76,12 @@ queueRouter.post('/join-ranked/:botId', async (c) => {
if (!parsed.success) { if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400) return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400)
} }
const { paymentId, pubkey } = parsed.data const { paymentId } = parsed.data
// Verify bot ownership in production // Verify bot ownership in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) { const ownerCheck = await verifyBotOwner(c, botId)
const botRows = await db.select({ publicKey: schema.bots.publicKey }) if (ownerCheck !== true) return ownerCheck
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else {
// No pubkey supplied — fall back to bot-secret auth (Authorization
// header or ?bot_id=&secret= query params, same as /api/fights/poll).
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
if (botOrRes.botId !== botId) {
return c.json({ error: 'Unauthorized' }, 403)
}
}
} }
try { try {