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