4 Commits
Author SHA1 Message Date
DorianandClaude 10d4209675 fix(bots): guard possibly-undefined route param, bump arena image to 1.2.11
CI / check (push) Failing after 6m6s
tsc --noEmit (run standalone, not filtered through a shell wrapper that
silently swallowed the real exit code) caught what my own verification
missed the first time: c.req.param('name') is typed possibly-undefined
in this Hono router's inferred route map, and the podman build's own
`pnpm --filter server build` step (which runs the real tsc, unlike a
loosely-configured local check) failed on it — 1.2.10 was never
actually built with the :name/ai-config routes as a result. Added an
explicit guard (matches the 404 semantics of a missing param) to all
three new handlers.

Also bumps docker-compose.arena.yml to the 1.2.11 tag being built next.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 19:01:19 -04:00
DorianandClaude 41f1b93e9e feat(bots): AI-answer settings + claim-winnings UI for existing bots
Two gaps found while answering "where can I change the API key for
existing users from a webhook":

1. The AI-answer API key ("let BotFights answer for me") could only
   ever be set at the exact moment of bot creation on JoinBoutPage,
   because that's the only place the bot's own secret is ever in the
   browser's hands (Authorization: Bot <id>:<secret> was the sole auth
   path for POST/GET/DELETE /api/bots/ai-config). An existing bot's
   owner had no way back in to add, change, or remove their key later.

   Fixed: new owner-scoped routes at /api/bots/:name/ai-config
   (GET/POST/DELETE), authorized via verifyBotOwner (nostr JWT OR the
   bot's own secret — see bot-auth.ts), reusing the same underlying
   ai-bot-config storage. Wired into BotProfilePage's existing
   owner-only settings area, mirroring the webhook-management section's
   UX pattern (collapsed toggle, provider picker, masked key input,
   configured/remove state).

2. Investigating the payout side of the same question ("can we confirm
   the fighter wins all the cashu sats into their node wallet
   automatically") surfaced that GET /winnings/:botId and POST
   /claim/:paymentId existed on the backend but had NO frontend caller
   anywhere — a Cashu payout (the common case: winner has no NWC/
   Lightning-address wallet linked) minted a token that was completely
   invisible in the UI.

   Added a "claim your winnings" section to BotProfilePage, shown
   proactively (not behind a toggle — it's the owner's own money):
   lists unclaimed payouts with a CLAIM button, reveals the bearer
   token once claimed with a copy-to-clipboard action and guidance to
   paste it into any Cashu wallet (there's no "auto-deposit" for a
   bearer token the way NWC allows for Lightning — no destination
   address to push to).

Route-shadowing note: /:name/ai-config is a different segment count
than the existing bare /ai-config and /:name routes, so it can't
collide with either (unlike the /poll vs /:id and /ai-config vs /:name
bugs fixed earlier this session) — confirmed via the full route table.

13 new/updated tests in ai-config.test.ts (owner-JWT auth, wrong-owner
403, bot-secret still works via verifyBotOwner, no-auth 401). Full
server suite: 815/816 passing (only the same pre-existing CPU-load-
sensitive constant-time-comparison flake, confirmed unrelated and
passing in isolation). tsc --noEmit clean (server + frontend).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 14:54:40 -04:00
DorianandClaude c162d5ebe9 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>
2026-07-31 13:00:08 -04:00
DorianandClaude f5f57e60d9 fix(security): POST /api/auth/update trusted a client-supplied pubkey (IDOR)
Found live while wiring the "existing bot" AI-config settings UI: this
route parsed `pubkey` from the request body and used it directly to
select which bot row to update, with no check that it matched the
caller's actual authenticated identity. Any unauthenticated caller
could POST an arbitrary victim's pubkey plus a malicious webhookUrl,
profilePicUrl, or customization payload and silently hijack that bot
(e.g. redirect its webhook to an attacker-controlled endpoint).

Contrast with GET /me and POST /regenerate-secret, which both
correctly derive pubkey from the verified JWT via
extractPubkeyFromAuth and never trust a client-claimed identity — this
was the one route that didn't follow that pattern.

Fixed by deriving pubkey from the JWT exclusively; updateBotSchema no
longer declares a pubkey field at all (was the only schema-level
signal that the vulnerable code path existed). Frontend callers
updated to stop sending a pubkey they no longer need. Added a
regression suite (auth-update.test.ts) covering: 401 with no/garbage
auth, hijack-attempt-via-body-pubkey now 404s and leaves the victim's
row untouched, and legitimate self-updates still work when the body
happens to carry an unrelated pubkey field (ignored, not trusted).

Full server suite: 829/829 passing. tsc --noEmit clean (server +
frontend).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 12:06:36 -04:00
15 changed files with 691 additions and 73 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
services:
botfights-arena:
image: localhost:3000/lfg2025/botfights:1.2.9
image: localhost:3000/lfg2025/botfights:1.2.11
container_name: botfights-arena
restart: unless-stopped
ports:
+2 -2
View File
@@ -380,7 +380,7 @@ export function useNostr() {
const res = await authFetch('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, customization }),
body: JSON.stringify({ customization }),
})
if (!res.ok) {
@@ -404,7 +404,7 @@ export function useNostr() {
const res = await authFetch('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
body: JSON.stringify({ webhookUrl: newUrl }),
})
const data = await res.json()
+3 -7
View File
@@ -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
+239
View File
@@ -2,6 +2,7 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useNostr, type NostrProfile } from '../composables/useNostr'
import { authFetch } from '../lib/nostr-auth'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue'
@@ -97,6 +98,17 @@ const showCustomize = ref(false)
const isSaving = ref(false)
const custError = ref('')
// Claim winnings (Cashu payouts sitting unclaimed — see payments.ts payWinner:
// mints a bearer token server-side when the winner has no NWC/Lightning-address
// wallet linked, which is the common case for a Cashu-primary bot). Fetched
// proactively (not behind a toggle) since this is the owner's own money.
interface UnclaimedWinning { paymentId: string; amountSats: number }
const unclaimedWinnings = ref<UnclaimedWinning[]>([])
const claimingId = ref<string | null>(null)
const claimError = ref('')
const claimedTokens = ref<{ paymentId: string; amountSats: number; token: string }[]>([])
const tokenCopiedId = ref<string | null>(null)
// Webhook management
const showWebhook = ref(false)
const webhookInput = ref('')
@@ -106,6 +118,68 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
const webhookError = ref('')
const webhookSuccess = ref('')
// AI-answer settings (existing bot — see /api/bots/:name/ai-config). Same
// feature as JoinBoutPage's creation-time setup, but reachable afterward:
// that flow only ever had the bot's own secret in hand at the moment of
// creation, with nowhere to come back to later.
const showAiConfig = ref(false)
const aiConfigLoaded = ref(false)
const aiConfigured = ref(false)
const aiConfigProvider = ref<'anthropic' | 'openai' | null>(null)
const aiProviderInput = ref<'anthropic' | 'openai'>('anthropic')
const aiApiKeyInput = ref('')
const aiConfigSaving = ref(false)
const aiConfigError = ref('')
async function loadAiConfig() {
if (!stats.value || aiConfigLoaded.value) return
try {
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`)
if (res.ok) {
const data = await res.json() as { configured: boolean; provider: 'anthropic' | 'openai' | null }
aiConfigured.value = data.configured
aiConfigProvider.value = data.provider
}
} catch (err) {
console.warn('[BotProfile] ai-config load failed:', err)
}
aiConfigLoaded.value = true
}
async function saveAiConfig() {
if (!aiApiKeyInput.value.trim() || aiConfigSaving.value) return
aiConfigSaving.value = true
aiConfigError.value = ''
try {
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: aiProviderInput.value, apiKey: aiApiKeyInput.value.trim() }),
})
const data = await res.json()
if (!res.ok) {
aiConfigError.value = data.error || 'Failed to save API key.'
return
}
aiConfigured.value = true
aiConfigProvider.value = aiProviderInput.value
aiApiKeyInput.value = '' // never keep the raw key in page state longer than needed
} catch {
aiConfigError.value = 'Connection failed. Try again.'
} finally {
aiConfigSaving.value = false
}
}
async function removeAiConfig() {
try {
await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, { method: 'DELETE' })
} finally {
aiConfigured.value = false
aiConfigProvider.value = null
}
}
// Setup guide
const showSetupGuide = ref(false)
const isRegenerating = ref(false)
@@ -240,6 +314,45 @@ async function saveCustomization() {
isSaving.value = false
}
async function fetchUnclaimedWinnings() {
if (!stats.value || !isOwner.value) return
try {
const res = await authFetch(`/api/payments/winnings/${stats.value.id}`)
if (res.ok) {
const data = await res.json() as { unclaimed: UnclaimedWinning[] }
unclaimedWinnings.value = data.unclaimed || []
}
} catch (err) {
console.warn('[BotProfile] winnings fetch failed:', err)
}
}
async function claimWinning(paymentId: string, amountSats: number) {
if (claimingId.value) return
claimingId.value = paymentId
claimError.value = ''
try {
const res = await authFetch(`/api/payments/claim/${paymentId}`, { method: 'POST' })
const data = await res.json()
if (!res.ok) {
claimError.value = data.error || 'Claim failed.'
return
}
claimedTokens.value.unshift({ paymentId, amountSats, token: data.cashuToken })
unclaimedWinnings.value = unclaimedWinnings.value.filter(w => w.paymentId !== paymentId)
} catch {
claimError.value = 'Network error claiming winnings.'
} finally {
claimingId.value = null
}
}
function copyClaimedToken(paymentId: string, token: string) {
navigator.clipboard.writeText(token)
tokenCopiedId.value = paymentId
setTimeout(() => { if (tokenCopiedId.value === paymentId) tokenCopiedId.value = null }, 2500)
}
async function testWebhook() {
if (!stats.value || isTestingWebhook.value) return
isTestingWebhook.value = true
@@ -343,6 +456,10 @@ onMounted(async () => {
}).catch(err => console.warn('[BotProfile] webhook test failed:', err))
}
// Unclaimed Cashu winnings (non-blocking, owner only — fetchUnclaimedWinnings
// itself checks isOwner, but stats must be loaded first)
fetchUnclaimedWinnings()
// Poll queue for "choose your fight"
pollQueue()
pollHandle = setInterval(pollQueue, 4000)
@@ -602,6 +719,56 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- Unclaimed Cashu winnings (owner only) shown proactively, not
behind a toggle: this is real money waiting on the winner.
Payouts land here (instead of an auto-deposit) whenever the
winner has no NWC/Lightning-address wallet linked, which is
the common case for a Cashu-primary bot. -->
<div v-if="isOwner && unclaimedWinnings.length > 0" class="mt-2 border-2 border-neon-yellow/50 bg-neon-yellow/10 p-3">
<p class="font-display font-bold text-xs tracking-wider text-neon-yellow mb-2">
🏆 YOU WON {{ unclaimedWinnings.reduce((s, w) => s + w.amountSats, 0) }} SATS CLAIM YOUR CASHU
</p>
<div v-for="w in unclaimedWinnings" :key="w.paymentId" class="flex items-center justify-between gap-2 mb-1.5 last:mb-0">
<span class="font-mono text-[10px] text-text-secondary">{{ w.amountSats }} sats</span>
<button
class="px-3 py-1.5 bg-neon-yellow/20 border border-neon-yellow/50 text-neon-yellow
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-yellow/30 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="claimingId === w.paymentId"
@click="claimWinning(w.paymentId, w.amountSats)"
>
{{ claimingId === w.paymentId ? 'CLAIMING...' : 'CLAIM' }}
</button>
</div>
<p v-if="claimError" class="font-mono text-[10px] text-ko mt-1.5">{{ claimError }}</p>
</div>
<!-- Just-claimed tokens: bearer instruments, revealed once. Copy
into any Cashu wallet (e.g. Minibits) to redeem there's no
"auto-deposit" for Cashu the way NWC allows for Lightning,
since a bearer token has no destination address to push to. -->
<div v-if="claimedTokens.length > 0" class="mt-2 border border-neon-green/40 bg-neon-green/5 p-3 space-y-2">
<div v-for="c in claimedTokens" :key="c.paymentId">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-green mb-1">
CLAIMED {{ c.amountSats }} SATS paste into your Cashu wallet
</p>
<div class="flex items-center gap-2">
<p class="flex-1 font-mono text-[9px] text-text-secondary break-all bg-black/30 border border-border p-1.5">
{{ c.token }}
</p>
<button
class="px-2 py-1.5 border border-neon-green/40 text-neon-green
font-display font-bold text-[9px] tracking-wider
hover:bg-neon-green/10 transition-all flex-shrink-0"
@click="copyClaimedToken(c.paymentId, c.token)"
>
{{ tokenCopiedId === c.paymentId ? 'COPIED' : 'COPY' }}
</button>
</div>
</div>
</div>
<!-- Wallet connection (owner only) -->
<div v-if="isOwner" class="mt-3">
<WalletConnect />
@@ -834,6 +1001,78 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- AI-answer settings (owner only, bots only): "let BotFights
answer for me" via an operator-supplied Anthropic/OpenAI key.
Same feature JoinBoutPage offers at creation time, now also
reachable afterward for changing/rotating the key, or
turning it on for a bot that skipped it at creation. -->
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
<button
class="w-full py-2 border border-border text-text-secondary font-display font-bold text-[10px]
tracking-wider hover:border-neon-purple/40 hover:text-neon-purple transition-all text-center"
@click="showAiConfig = !showAiConfig; if (showAiConfig) loadAiConfig()"
>
{{ showAiConfig ? 'HIDE' : 'AI ANSWER' }} SETTINGS
</button>
<div v-if="showAiConfig" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
<p class="font-mono text-[10px] text-text-muted">
Let BotFights answer poll-mode fights for you using your own
Anthropic or OpenAI API key no script or webhook required.
</p>
<div v-if="!aiConfigLoaded" class="font-mono text-[10px] text-text-muted">Loading...</div>
<div v-else-if="aiConfigured" class="flex items-center justify-between p-2 border border-neon-green/30 bg-neon-green/5">
<span class="font-mono text-[10px] text-neon-green">
Configured ({{ aiConfigProvider }})
</span>
<button
class="px-2 py-1 border border-ko/40 text-ko font-display font-bold text-[9px] tracking-wider hover:bg-ko/10 transition-all"
@click="removeAiConfig"
>
REMOVE
</button>
</div>
<div v-else class="space-y-2">
<div class="flex gap-2">
<button
v-for="p in (['anthropic', 'openai'] as const)"
:key="p"
class="flex-1 py-1.5 border font-display font-bold text-[9px] tracking-wider transition-all"
:class="aiProviderInput === p
? 'border-neon-purple/50 bg-neon-purple/10 text-neon-purple'
: 'border-border text-text-muted hover:border-neon-purple/30'"
@click="aiProviderInput = p"
>
{{ p.toUpperCase() }}
</button>
</div>
<input
v-model="aiApiKeyInput"
type="password"
placeholder="sk-..."
class="w-full px-2 py-1.5 bg-black/30 border border-border font-mono text-[10px] text-text-primary
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
@keyup.enter="saveAiConfig"
/>
<button
class="w-full py-1.5 bg-neon-purple/10 border border-neon-purple/40 text-neon-purple
font-display font-bold text-[10px] tracking-wider
hover:bg-neon-purple/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!aiApiKeyInput.trim() || aiConfigSaving"
@click="saveAiConfig"
>
{{ aiConfigSaving ? 'SAVING...' : 'SAVE KEY' }}
</button>
</div>
<p v-if="aiConfigError" class="font-mono text-[10px] text-ko">{{ aiConfigError }}</p>
</div>
</div>
<!-- Setup guide download (owner only, bots only) -->
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
<button
+3 -1
View File
@@ -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()
+10 -2
View File
@@ -132,14 +132,22 @@ describe('registerHumanSchema', () => {
})
describe('updateBotSchema', () => {
const base = { pubkey: 'a'.repeat(64) }
it('accepts pubkey only (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
const base = {}
it('accepts empty body (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
it('accepts webhook update', () => {
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
})
it('rejects file:// webhook', () => {
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
})
// SECURITY REGRESSION: pubkey must never be a schema field here. POST
// /api/auth/update derives identity from the verified JWT
// (extractPubkeyFromAuth), not from client body — see server/src/routes/auth.ts.
// A pubkey field in this schema previously let an unauthenticated caller
// claim any bot as their own and hijack its webhook/customization.
it('does not declare a pubkey field (identity comes from the JWT, not the body)', () => {
expect('pubkey' in updateBotSchema.shape).toBe(false)
})
})
// --- Fight schemas ---
+13 -7
View File
@@ -42,8 +42,13 @@ export const registerHumanSchema = z.object({
avatarSeed: z.string().min(1).max(50).optional(),
})
// pubkey is intentionally NOT part of this schema: POST /api/auth/update
// derives the caller's identity from their verified JWT (extractPubkeyFromAuth),
// never from the request body — a client-supplied pubkey here would let any
// caller act as any other bot owner. Kept accepting-but-ignoring the field
// would be more confusing than just not declaring it; the frontend no longer
// sends it either.
export const updateBotSchema = z.object({
pubkey: pubkeySchema,
webhookUrl: httpUrlSchema.max(2048).optional(),
profilePicUrl: httpUrlSchema.max(2048).optional(),
customization: z.record(z.string(), z.unknown()).optional().nullable(),
@@ -83,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({
@@ -104,9 +110,7 @@ export const zapSchema = z.object({
amountSats: satsSchema,
})
export const disconnectWalletSchema = z.object({
pubkey: pubkeySchema,
})
export const disconnectWalletSchema = z.object({})
// --- Tournament schemas ---
@@ -129,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 ---
+38
View File
@@ -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
}
+67
View File
@@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
const TEST_PUBKEY = 'b'.repeat(64)
const mockBotRow = {
id: 'bot_test123',
name: 'testbot',
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
webhookUrl: 'http://poll.local/',
publicKey: TEST_PUBKEY,
}
vi.mock('../db/index.js', () => ({
@@ -52,11 +54,13 @@ const REAL_SECRET = 'test-bot-secret-1234567890'
mockBotRow.secretHash = createHash('sha256').update(REAL_SECRET).digest('hex')
const { botsRouter } = await import('./bots.js')
const { createJwt } = await import('../middleware/jwt.js')
const app = new Hono()
app.route('/api/bots', botsRouter)
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
const OWNER_JWT_AUTH = { Authorization: `Bearer ${createJwt(TEST_PUBKEY)}` }
beforeEach(() => { store.clear() })
@@ -131,3 +135,66 @@ describe('bots ai-config routes', () => {
expect(res.status).toBe(401)
})
})
// --- Existing-bot owner settings page: /api/bots/:name/ai-config ---
// These exist because the routes above require the bot's own secret, which
// is only ever available at the exact moment of creation (JoinBoutPage) —
// there was previously no way to add/change/remove an AI key for a bot
// after that moment, even for its nostr-logged-in owner.
describe('bots :name/ai-config routes (existing-bot owner settings)', () => {
it('GET /api/bots/:name/ai-config with a valid owner JWT returns configured status', async () => {
const res = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ configured: false, provider: null })
})
it('POST /api/bots/:name/ai-config with a valid owner JWT sets the config', async () => {
const res = await app.request('/api/bots/testbot/ai-config', {
method: 'POST',
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ configured: true, provider: 'anthropic' })
// Same underlying storage as the bot-secret path — visible either way.
const check = await app.request('/api/bots/ai-config', { headers: AUTH })
expect(await check.json()).toEqual({ configured: true, provider: 'anthropic' })
})
it('DELETE /api/bots/:name/ai-config with a valid owner JWT removes the config', async () => {
await app.request('/api/bots/testbot/ai-config', {
method: 'POST',
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
})
const del = await app.request('/api/bots/testbot/ai-config', { method: 'DELETE', headers: OWNER_JWT_AUTH })
expect(del.status).toBe(200)
const check = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
expect(await check.json()).toEqual({ configured: false, provider: null })
})
it('rejects a JWT for a DIFFERENT pubkey than the bot owner (403)', async () => {
const wrongOwnerJwt = { Authorization: `Bearer ${createJwt('c'.repeat(64))}` }
const res = await app.request('/api/bots/testbot/ai-config', {
method: 'POST',
headers: { ...wrongOwnerJwt, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
})
expect(res.status).toBe(403)
})
it('rejects requests with no auth at all (401)', async () => {
const res = await app.request('/api/bots/testbot/ai-config')
expect(res.status).toBe(401)
})
it('also accepts the bot\'s own secret (Authorization: Bot id:secret) via verifyBotOwner', async () => {
const res = await app.request('/api/bots/testbot/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
})
expect(res.status).toBe(200)
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { authRouter } from './auth.js'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { createJwt } from '../middleware/jwt.js'
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createHash, randomBytes } from 'crypto'
import { nanoid } from 'nanoid'
const app = new Hono()
app.route('/api/auth', authRouter)
async function seedBot(pubkey: string, overrides: Partial<typeof schema.bots.$inferInsert> = {}) {
const id = overrides.id || nanoid(12)
const secret = randomBytes(32).toString('hex')
await db.insert(schema.bots).values({
id,
name: overrides.name || `t${Date.now().toString(36).slice(-8)}`,
webhookUrl: overrides.webhookUrl || 'http://poll.local/',
avatarSeed: overrides.avatarSeed || 'seed',
archetype: overrides.archetype || 'standard',
secretHash: createHash('sha256').update(secret).digest('hex'),
publicKey: pubkey,
profilePicUrl: overrides.profilePicUrl ?? null,
customization: overrides.customization ?? null,
createdAt: overrides.createdAt || new Date().toISOString(),
})
return id
}
// SECURITY REGRESSION SUITE for POST /api/auth/update.
//
// This route previously trusted a client-supplied `pubkey` field in the
// request body with no verification against the caller's actual identity —
// any unauthenticated caller could pass a victim's pubkey and hijack their
// bot's webhook/profilePicUrl/customization. Found live during 09-06
// (ai-config UI work) by contrast with GET /me and POST /regenerate-secret,
// which both correctly derive identity from the verified JWT via
// extractPubkeyFromAuth. Fixed to always derive pubkey from the JWT; the
// body no longer even has a pubkey field (see updateBotSchema).
describe('POST /api/auth/update — identity comes from the JWT, not the body', () => {
it('rejects an unauthenticated request (no Authorization header)', async () => {
const res = await app.request('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customization: { archetype: 'tank' } }),
})
expect(res.status).toBe(401)
})
it('rejects a garbage/tampered Bearer token', async () => {
const res = await app.request('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer not-a-real-jwt' },
body: JSON.stringify({ customization: { archetype: 'tank' } }),
})
expect(res.status).toBe(401)
})
it('cannot hijack another bot by passing its pubkey in the body', async () => {
const victimSk = generateSecretKey()
const victimPk = getPublicKey(victimSk)
const victimId = await seedBot(victimPk, { webhookUrl: 'http://victim.local/original' })
// Attacker has their own valid session (their own JWT) but a DIFFERENT
// bot — no bot row at all, in this case.
const attackerPk = getPublicKey(generateSecretKey())
const attackerToken = createJwt(attackerPk)
const res = await app.request('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${attackerToken}` },
// Old exploit shape: claim to be the victim via a body field.
body: JSON.stringify({ pubkey: victimPk, webhookUrl: 'http://poll.local/attacker-controlled' }),
})
// Attacker has no bot of their own -> 404, NOT a successful update of
// the victim's bot.
expect(res.status).toBe(404)
const rows = await db.select({ webhookUrl: schema.bots.webhookUrl })
.from(schema.bots)
.where(eq(schema.bots.id, victimId))
.limit(1)
expect(rows[0].webhookUrl).toBe('http://victim.local/original')
})
it('updates the caller\'s own bot, derived from their JWT, ignoring a body pubkey', async () => {
const sk = generateSecretKey()
const pk = getPublicKey(sk)
const id = await seedBot(pk, { webhookUrl: 'http://poll.local/' })
const token = createJwt(pk)
const someoneElsesPk = getPublicKey(generateSecretKey())
const res = await app.request('/api/auth/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
// Even if a stale client sends an unrelated pubkey, the server must
// still act on the JWT-derived identity, not this field.
body: JSON.stringify({ pubkey: someoneElsesPk, customization: { archetype: 'shark' } }),
})
expect(res.status).toBe(200)
const rows = await db.select({ customization: schema.bots.customization })
.from(schema.bots)
.where(eq(schema.bots.id, id))
.limit(1)
expect(JSON.parse(rows[0].customization || '{}').archetype).toBe('shark')
})
})
+16 -2
View File
@@ -324,11 +324,25 @@ authRouter.post('/register-human', rateLimit(600_000, 10), async (c) => {
// Update bot webhook and/or customization (requires pubkey match)
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
// SECURITY: pubkey MUST come from the verified JWT, never from the request
// body. This handler previously trusted a client-supplied `pubkey` field
// with no cross-check against the caller's actual authenticated identity —
// any unauthenticated caller could POST an arbitrary victim's pubkey plus
// a malicious webhookUrl/profilePicUrl/customization and silently hijack
// that bot (e.g. redirect its webhook to an attacker-controlled endpoint).
// Found live during the ai-config UI work (09-06) by contrast with
// /regenerate-secret and GET /me, which both correctly derive pubkey from
// extractPubkeyFromAuth and never trust a client-claimed identity.
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
if (!pubkey) {
return c.json({ error: 'Authentication required.' }, 401)
}
const parsed = updateBotSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: 'Invalid pubkey.' }, 400)
return c.json({ error: 'Invalid request body.' }, 400)
}
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data
const { webhookUrl, profilePicUrl, customization: rawCustomization } = parsed.data
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
.from(schema.bots)
+75 -1
View File
@@ -9,7 +9,7 @@ import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
import { authenticateBot } from '../middleware/bot-auth.js'
import { authenticateBot, verifyBotOwner } from '../middleware/bot-auth.js'
import { setAiBotConfig, getAiBotConfig, deleteAiBotConfig, type LlmProvider } from '../engine/ai-bot-config.js'
export const botsRouter = new Hono()
@@ -193,6 +193,80 @@ botsRouter.delete('/ai-config', async (c) => {
return c.json({ configured: false })
})
// --- Same feature, for an EXISTING bot's owner settings page ---
// The routes above require the bot's own secret (Authorization: Bot
// <id>:<secret>), which only the JoinBoutPage bot-creation flow has in hand
// at the moment of creation — it's never persisted anywhere the browser can
// re-fetch it. Before this, there was no way for an existing bot's owner to
// add, change, or remove their AI key later; they'd have to still be on the
// exact creation tab. These are owner-scoped by :name + nostr JWT
// (verifyBotOwner also accepts the bot's own secret, so an AI agent that
// happens to hold both could use either path — no harm either way).
//
// MUST be registered before GET /:name below for the same reason as
// /ai-config above (Hono resolves same-segment-count routes in registration
// order) — but :name/ai-config is a DIFFERENT segment count than :name, so
// it can't actually collide with it; kept adjacent for readability, not
// because ordering is load-bearing here.
botsRouter.get('/:name/ai-config', async (c) => {
const name = c.req.param('name')
if (!name) return c.json({ error: 'Bot not found.' }, 404)
const rows = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
.limit(1)
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
const ownerCheck = await verifyBotOwner(c, rows[0].id)
if (ownerCheck !== true) return ownerCheck
const config = getAiBotConfig(rows[0].id)
return c.json({ configured: !!config, provider: config?.provider ?? null })
})
botsRouter.post('/:name/ai-config', rateLimit(60_000, 10), async (c) => {
const name = c.req.param('name')
if (!name) return c.json({ error: 'Bot not found.' }, 404)
const rows = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
.limit(1)
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
const ownerCheck = await verifyBotOwner(c, rows[0].id)
if (ownerCheck !== true) return ownerCheck
const body = await c.req.json().catch(() => ({})) as { provider?: string; apiKey?: string }
const provider = body.provider
const apiKey = body.apiKey?.trim()
if (!provider || !AI_PROVIDERS.includes(provider as LlmProvider)) {
return c.json({ error: `provider must be one of: ${AI_PROVIDERS.join(', ')}` }, 400)
}
if (!apiKey || apiKey.length < 8 || apiKey.length > 512) {
return c.json({ error: 'apiKey is required (8-512 chars).' }, 400)
}
setAiBotConfig(rows[0].id, { provider: provider as LlmProvider, apiKey })
return c.json({ configured: true, provider })
})
botsRouter.delete('/:name/ai-config', async (c) => {
const name = c.req.param('name')
if (!name) return c.json({ error: 'Bot not found.' }, 404)
const rows = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
.limit(1)
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
const ownerCheck = await verifyBotOwner(c, rows[0].id)
if (ownerCheck !== true) return ownerCheck
deleteAiBotConfig(rows[0].id)
return c.json({ configured: false })
})
// Get single bot profile
botsRouter.get('/:name', async (c) => {
const name = c.req.param('name')
+28 -10
View File
@@ -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',
}),
+73 -22
View File
@@ -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
View File
@@ -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 {