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
+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 {