fix: add input validation, Zod schemas, rate limiting, and IP trust

- Add Zod schema for webhook response parsing (orchestrator.ts)
- Add Zod schemas for POST /respond and /react request bodies
- Add safe integer validation for batch count param
- Prefer cf-connecting-ip over spoofable x-forwarded-for
- Add ID format validation on URL params
- Add rate limiting on /auth/login (30/min) and /update (10/min)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 07:30:49 +00:00
co-authored by Claude Opus 4.6
parent 276cbd6e31
commit 5bfb63aa7f
6 changed files with 68 additions and 23 deletions
+8
View File
@@ -87,6 +87,9 @@ importers:
nostr-tools:
specifier: ^2.23.3
version: 2.23.3(typescript@5.9.3)
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
@@ -3385,6 +3388,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots:
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
@@ -6708,3 +6714,5 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
zod@4.3.6: {}
+2 -1
View File
@@ -19,7 +19,8 @@
"drizzle-orm": "^0.40.1",
"hono": "^4.7.6",
"nanoid": "^5.1.5",
"nostr-tools": "^2.23.3"
"nostr-tools": "^2.23.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
+16 -4
View File
@@ -1,3 +1,4 @@
import { z } from 'zod'
import { nanoid } from 'nanoid'
import { toError } from '../lib/utils.js'
import { db, schema, sqlite } from '../db/index.js'
@@ -17,6 +18,11 @@ import { getCurrentSeason } from './seasons.js'
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
const webhookResponseSchema = z.object({
answer: z.string().nullable().optional(),
trash_talk: z.string().optional(),
}).passthrough()
interface BotRecord {
id: string
name: string
@@ -184,17 +190,23 @@ async function callWebhook(
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
let data: { answer?: string; trash_talk?: string }
let parsed: unknown
try {
data = JSON.parse(text)
parsed = JSON.parse(text)
} catch {
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
const data = webhookResponseSchema.safeParse(parsed)
if (!data.success) {
console.log(`[webhook] ${url} invalid response shape in ${elapsed}ms: ${data.error.message}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
// Enforce size limits on fields
const answer = data.answer ? data.answer.slice(0, MAX_ANSWER_LENGTH) : null
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : undefined
const answer = data.data.answer ? data.data.answer.slice(0, MAX_ANSWER_LENGTH) : null
const trashTalk = data.data.trash_talk ? data.data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : undefined
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
return {
+5 -3
View File
@@ -24,9 +24,11 @@ export function rateLimit(windowMs: number, maxHits: number) {
return async (c: Context, next: Next) => {
if (isDev) return next()
// Extract real IP — handle comma-separated x-forwarded-for (first = client)
const xff = c.req.header('x-forwarded-for')
const realIp = xff ? xff.split(',')[0].trim() : c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || 'unknown'
// Extract real IP — prefer trusted proxy headers over spoofable x-forwarded-for
const realIp = c.req.header('cf-connecting-ip')
|| c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
const key = realIp
const now = Date.now()
const entry = hitCounts.get(key)
+3 -3
View File
@@ -26,8 +26,8 @@ authRouter.get("/check-name/:name", async (c) => {
return c.json({ available: existing.length === 0 })
})
// Login with Nostr pubkey
authRouter.post('/login', async (c) => {
// Login with Nostr pubkey (rate limited: 30 per minute per IP)
authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
const body = await c.req.json()
const { pubkey } = body
@@ -295,7 +295,7 @@ authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => {
})
// Update bot webhook and/or customization (requires pubkey match)
authRouter.post('/update', async (c) => {
authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
+34 -12
View File
@@ -1,3 +1,4 @@
import { z } from 'zod'
import { Hono } from 'hono'
import { logger } from '../lib/logger.js'
import { streamSSE } from 'hono/streaming'
@@ -11,6 +12,18 @@ import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.js'
import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js'
// --- Request validation schemas ---
const respondSchema = z.object({
answer: z.string().min(1).max(2000),
trashTalk: z.string().max(200).optional(),
})
const reactSchema = z.object({
emoji: z.string().min(1),
})
const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id)
export const fightsRouter = new Hono()
// Track spectator counts per fight
@@ -84,6 +97,7 @@ fightsRouter.get('/', async (c) => {
// Get a single fight with rounds and bot details
fightsRouter.get('/:id', async (c) => {
const id = c.req.param('id')
if (!isValidId(id)) return c.json({ error: 'Invalid ID format.' }, 400)
const fightRows = await db.select()
.from(schema.fights)
@@ -188,14 +202,13 @@ fightsRouter.post('/mock/:botId', async (c) => {
// Start a batch of mock fights (for seeding or overnight loop)
fightsRouter.post('/mock/batch/:count', async (c) => {
if (!isDev) return c.json({ error: 'Fight loop disabled in production.' }, 403)
const count = parseInt(c.req.param('count')) || 10
const capped = Math.min(count, 500)
const count = Math.min(Math.max(1, parseInt(c.req.param('count')) || 10), 500)
startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' })
.then(() => logger.info('fights', `batch of ${capped} fights completed`))
startFightLoop({ maxFights: count, intervalMs: 500, matchmakingStyle: 'mixed' })
.then(() => logger.info('fights', `batch of ${count} fights completed`))
.catch(err => logger.error('fights', 'batch error', err))
return c.json({ message: `Started batch of ${capped} fights in background.` })
return c.json({ message: `Started batch of ${count} fights in background.` })
})
// Instant matchmaking
@@ -330,13 +343,16 @@ fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
fightsRouter.post('/:fightId/respond/:botId', async (c) => {
const fightId = c.req.param('fightId')
const botId = c.req.param('botId')
const body = await c.req.json()
if (!isValidId(fightId) || !isValidId(botId)) {
return c.json({ error: 'Invalid ID format.' }, 400)
}
const { answer, trashTalk } = body
if (!answer || typeof answer !== 'string') {
const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({})))
if (!parsed.success) {
return c.json({ error: 'Answer is required.' }, 400)
}
const { answer, trashTalk } = parsed.data
const accepted = submitHumanResponse(fightId, botId, answer, trashTalk)
if (!accepted) {
return c.json({ error: 'No pending challenge found. May have timed out.' }, 404)
@@ -348,8 +364,10 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
// SSE stream for live fight events
fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id')
const xff = c.req.header('x-forwarded-for')
const clientIp = xff ? xff.split(',')[0].trim() : c.req.header('x-real-ip') || 'unknown'
const clientIp = c.req.header('cf-connecting-ip')
|| c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
// Enforce per-IP SSE connection limit
const ipCount = ssePerIp.get(clientIp) || 0
@@ -426,8 +444,12 @@ fightsRouter.get('/:id/stream', (c) => {
// React to a fight
fightsRouter.post('/:id/react', async (c) => {
const fightId = c.req.param('id')
const body = await c.req.json<{ emoji?: string }>()
const emoji = body?.emoji
if (!isValidId(fightId)) {
return c.json({ error: 'Invalid ID format.' }, 400)
}
const parsed = reactSchema.safeParse(await c.req.json().catch(() => ({})))
const emoji = parsed.success ? parsed.data.emoji : null
if (!emoji || !VALID_REACTIONS.has(emoji)) {
return c.json({ error: 'Invalid reaction. Use: fist, fire, skull, 100, clown' }, 400)