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:
@@ -64,7 +64,6 @@ export function useWallet() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey.value,
|
||||
method: 'nwc',
|
||||
connectionData: connectionString,
|
||||
}),
|
||||
@@ -93,7 +92,6 @@ export function useWallet() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pubkey: pubkey.value,
|
||||
method: 'lnaddress',
|
||||
connectionData: address,
|
||||
}),
|
||||
@@ -114,8 +112,6 @@ export function useWallet() {
|
||||
|
||||
await authFetch('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
})
|
||||
|
||||
walletMethod.value = null
|
||||
@@ -129,7 +125,7 @@ export function useWallet() {
|
||||
async function checkWalletStatus(): Promise<void> {
|
||||
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) {
|
||||
const data = await res.json()
|
||||
isWalletConnected.value = data.connected
|
||||
@@ -148,7 +144,7 @@ export function useWallet() {
|
||||
const invoiceRes = await authFetch('/api/payments/create-invoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ botId, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ botId }),
|
||||
})
|
||||
|
||||
if (!invoiceRes.ok) {
|
||||
@@ -179,7 +175,7 @@ export function useWallet() {
|
||||
await authFetch(`/api/payments/confirm/${paymentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ preimage }),
|
||||
})
|
||||
paymentStatus.value = 'confirmed'
|
||||
return paymentId
|
||||
|
||||
@@ -717,10 +717,12 @@ async function fightRanked() {
|
||||
// don't create a duplicate Lightning invoice via payEntryFee().
|
||||
const paymentId = cashuPaymentId.value ?? await payEntryFee(bot.value.id)
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentId, pubkey: pubkey.value }),
|
||||
body: JSON.stringify({ paymentId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
|
||||
@@ -88,14 +88,15 @@ export const withdrawSchema = z.object({
|
||||
// --- Payment schemas ---
|
||||
|
||||
export const connectWalletSchema = z.object({
|
||||
pubkey: pubkeySchema,
|
||||
method: z.enum(['nwc', 'lnaddress', 'cashu_mint']),
|
||||
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({
|
||||
botId: idSchema,
|
||||
pubkey: pubkeySchema.optional(),
|
||||
})
|
||||
|
||||
export const submitCashuSchema = z.object({
|
||||
@@ -109,9 +110,7 @@ export const zapSchema = z.object({
|
||||
amountSats: satsSchema,
|
||||
})
|
||||
|
||||
export const disconnectWalletSchema = z.object({
|
||||
pubkey: pubkeySchema,
|
||||
})
|
||||
export const disconnectWalletSchema = z.object({})
|
||||
|
||||
// --- Tournament schemas ---
|
||||
|
||||
@@ -134,9 +133,11 @@ export const startTournamentSchema = z.object({
|
||||
|
||||
// --- 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({
|
||||
paymentId: idSchema,
|
||||
pubkey: pubkeySchema.optional(),
|
||||
})
|
||||
|
||||
// --- Docs schemas ---
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createHash, timingSafeEqual } from 'crypto'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Context } from 'hono'
|
||||
import { extractPubkeyFromAuth } from './jwt.js'
|
||||
|
||||
export interface BotAuthContext {
|
||||
botId: string
|
||||
@@ -68,3 +69,40 @@ export async function authenticateBot(c: Context): Promise<BotAuthContext | Resp
|
||||
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
|
||||
}
|
||||
|
||||
@@ -56,6 +56,16 @@ vi.mock('../middleware/rate-limit.js', () => ({
|
||||
const { paymentsRouter } = await import('./payments.js')
|
||||
const { db, schema } = await import('../db/index.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() {
|
||||
const app = new Hono()
|
||||
@@ -68,12 +78,22 @@ describe('payments routes', () => {
|
||||
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 res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
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)
|
||||
const json = await res.json() as { error: string }
|
||||
@@ -85,9 +105,8 @@ describe('payments routes', () => {
|
||||
// db.select will return empty array by default
|
||||
const res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({
|
||||
pubkey: 'a'.repeat(64),
|
||||
method: 'nwc',
|
||||
connectionData: 'nostr+walletconnect://test',
|
||||
}),
|
||||
@@ -160,14 +179,14 @@ describe('payments routes', () => {
|
||||
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 res = await app.request('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('disconnect-wallet wipes connection data and sets hasWallet=false', async () => {
|
||||
@@ -184,8 +203,8 @@ describe('payments routes', () => {
|
||||
const app = makeApp()
|
||||
const res = await app.request('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: 'a'.repeat(64) }),
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json() as { success: boolean }
|
||||
@@ -314,9 +333,8 @@ describe('payment security — attack vectors', () => {
|
||||
const app = makeApp()
|
||||
const res = await app.request('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({
|
||||
pubkey: 'a'.repeat(64),
|
||||
method: 'paypal',
|
||||
connectionData: 'malicious://data',
|
||||
}),
|
||||
|
||||
@@ -6,17 +6,32 @@ import { eq } from 'drizzle-orm'
|
||||
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
|
||||
import { encrypt, decrypt } from '../engine/crypto.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()
|
||||
|
||||
// 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) => {
|
||||
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(() => ({})))
|
||||
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
|
||||
const botRows = await db.select({ id: schema.bots.id })
|
||||
@@ -59,9 +74,11 @@ paymentsRouter.post('/connect-wallet', async (c) => {
|
||||
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) => {
|
||||
const pubkey = c.req.query('pubkey')
|
||||
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||
if (!pubkey) return c.json({ connected: false, method: null })
|
||||
|
||||
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) => {
|
||||
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)
|
||||
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 (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
||||
return c.json({ error: 'Missing pubkey' }, 400)
|
||||
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)
|
||||
@@ -130,7 +149,7 @@ paymentsRouter.post('/confirm/:paymentId', rateLimit(60_000, 20), async (c) => {
|
||||
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)
|
||||
.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
|
||||
if (payment.direction !== 'in') return c.json({ error: 'Cannot confirm outbound payments' }, 400)
|
||||
|
||||
// Verify caller owns this payment's bot
|
||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
||||
// Verify caller owns this payment's bot — pubkey comes from the verified
|
||||
// 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 })
|
||||
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
|
||||
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
||||
return c.json({ error: 'Unauthorized' }, 403)
|
||||
}
|
||||
} 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)
|
||||
@@ -204,18 +227,42 @@ paymentsRouter.post('/submit-cashu', async (c) => {
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
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({
|
||||
paymentId: schema.payments.id,
|
||||
cashuToken: schema.payments.cashuToken,
|
||||
hasToken: schema.payments.cashuToken,
|
||||
amountSats: schema.payments.amountSats,
|
||||
}).from(schema.payments)
|
||||
.where(eq(schema.payments.botId, botId))
|
||||
|
||||
// Filter in JS since drizzle doesn't easily combine multiple conditions
|
||||
const filtered = unclaimed.filter(p => p.cashuToken)
|
||||
// Filter in JS since drizzle doesn't easily combine multiple conditions.
|
||||
// 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 })
|
||||
})
|
||||
@@ -223,7 +270,6 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
|
||||
// POST /claim/:paymentId
|
||||
paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
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)
|
||||
.where(eq(schema.payments.id, paymentId))
|
||||
@@ -233,7 +279,11 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
|
||||
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) {
|
||||
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
||||
.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)
|
||||
}
|
||||
} 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)
|
||||
@@ -255,9 +305,10 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
|
||||
|
||||
// DELETE /disconnect-wallet
|
||||
paymentsRouter.delete('/disconnect-wallet', async (c) => {
|
||||
const parsed = disconnectWalletSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
if (!parsed.success) return c.json({ error: formatZodError(parsed.error, {}, 'Missing pubkey') }, 400)
|
||||
const { pubkey } = parsed.data
|
||||
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||
if (!pubkey) {
|
||||
return c.json({ error: 'Authentication required.' }, 401)
|
||||
}
|
||||
|
||||
const botRows = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
|
||||
+12
-18
@@ -5,7 +5,7 @@ import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine
|
||||
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
||||
import { rateLimit } from '../middleware/rate-limit.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()
|
||||
|
||||
@@ -57,7 +57,14 @@ queueRouter.get('/ranked-status', (c) => {
|
||||
// Join ranked queue — requires confirmed payment + bot ownership.
|
||||
// Ownership can be proven either way, since ranked/staked fights are for
|
||||
// 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
|
||||
// primary registration path for AI agents per BOTFIGHTS.md, which
|
||||
// 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) {
|
||||
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
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
const ownerCheck = await verifyBotOwner(c, botId)
|
||||
if (ownerCheck !== true) return ownerCheck
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user