From 5bfb63aa7f9343a65b79caa1538ce13627d4d31c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 07:30:49 +0000 Subject: [PATCH] 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 --- pnpm-lock.yaml | 8 +++++ server/package.json | 3 +- server/src/engine/orchestrator.ts | 20 ++++++++++--- server/src/middleware/rate-limit.ts | 8 +++-- server/src/routes/auth.ts | 6 ++-- server/src/routes/fights.ts | 46 +++++++++++++++++++++-------- 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8039c16..c31637c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/server/package.json b/server/package.json index d58e91d..2a3b87a 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 523bf14..0f1aa62 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -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 { diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index ce61b01..9d21074 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -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) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index e32f7f2..d649e59 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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 diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index 7371768..501d8e0 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -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)