fix: add Zod enum validation for challenge types in webhook tester (BUG-S6)

Validates challenge type against the full CHALLENGE_TYPES enum before
processing. Invalid types now return 400 instead of silently falling
back to speed_blitz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 22:56:26 +00:00
co-authored by Claude Opus 4.6
parent b82c2755aa
commit 370d8643b7
2 changed files with 56 additions and 11 deletions
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { docsRouter } from './docs.js'
const app = new Hono()
app.route('/api/docs', docsRouter)
describe('docs webhook tester', () => {
it('rejects invalid challenge type', async () => {
const res = await app.request('/api/docs/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com/webhook', type: 'invalid_type_xss' }),
})
expect(res.status).toBe(400)
const json = await res.json() as { error: string }
expect(json.error).toContain('Invalid')
})
it('accepts valid challenge type', async () => {
// This will fail to actually reach the webhook but should pass validation
const res = await app.request('/api/docs/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com/webhook', type: 'speed_blitz' }),
})
// Should either succeed or fail at the fetch step — not at validation
// Status could be 200 (webhook responded) or 500 (fetch failed)
expect(res.status).not.toBe(400)
})
it('rejects missing URL', async () => {
const res = await app.request('/api/docs/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'speed_blitz' }),
})
expect(res.status).toBe(400)
})
})
+16 -11
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono'
import { z } from 'zod'
import { getAllChallengeTypes } from '../engine/challenges.js'
export const docsRouter = new Hono()
docsRouter.get('/webhook', (c) => {
@@ -166,23 +166,28 @@ docsRouter.get('/webhook', (c) => {
// POST /test — interactive webhook tester (no auth required)
docsRouter.post('/test', async (c) => {
const body = await c.req.json()
const { url, type } = body as { url?: string; type?: string }
if (!url || typeof url !== 'string') {
return c.json({ error: 'Missing "url" field' }, 400)
const validTypes = getAllChallengeTypes()
const testSchema = z.object({
url: z.string().url(),
type: z.enum(validTypes as [string, ...string[]]).optional(),
})
const parsed = testSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.issues[0]?.message || 'Invalid request' }, 400)
}
const { url, type } = parsed.data
// Basic URL validation — must be http(s), no private IPs
let parsed: URL
// Validate URL protocol and host
let parsedUrl: URL
try {
parsed = new URL(url)
parsedUrl = new URL(url)
} catch {
return c.json({ error: 'Invalid URL' }, 400)
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
return c.json({ error: 'URL must use http:// or https://' }, 400)
}
const host = parsed.hostname
const host = parsedUrl.hostname
if (host === 'localhost' || host === '127.0.0.1' || host.startsWith('192.168.') || host.startsWith('10.') || host.endsWith('.local')) {
return c.json({ error: 'Cannot test private/local URLs' }, 400)
}
@@ -197,7 +202,7 @@ docsRouter.post('/test', async (c) => {
}
const selectedType = (type && challengeTypes[type]) ? type : 'speed_blitz'
const ct = challengeTypes[selectedType]
const ct = challengeTypes[selectedType] || challengeTypes['speed_blitz']
const payload = {
fight_id: 'test_000000',