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:
co-authored by
Claude Opus 4.6
parent
b82c2755aa
commit
370d8643b7
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
import { getAllChallengeTypes } from '../engine/challenges.js'
|
import { getAllChallengeTypes } from '../engine/challenges.js'
|
||||||
|
|
||||||
export const docsRouter = new Hono()
|
export const docsRouter = new Hono()
|
||||||
|
|
||||||
docsRouter.get('/webhook', (c) => {
|
docsRouter.get('/webhook', (c) => {
|
||||||
@@ -166,23 +166,28 @@ docsRouter.get('/webhook', (c) => {
|
|||||||
// POST /test — interactive webhook tester (no auth required)
|
// POST /test — interactive webhook tester (no auth required)
|
||||||
docsRouter.post('/test', async (c) => {
|
docsRouter.post('/test', async (c) => {
|
||||||
const body = await c.req.json()
|
const body = await c.req.json()
|
||||||
const { url, type } = body as { url?: string; type?: string }
|
const validTypes = getAllChallengeTypes()
|
||||||
|
const testSchema = z.object({
|
||||||
if (!url || typeof url !== 'string') {
|
url: z.string().url(),
|
||||||
return c.json({ error: 'Missing "url" field' }, 400)
|
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
|
// Validate URL protocol and host
|
||||||
let parsed: URL
|
let parsedUrl: URL
|
||||||
try {
|
try {
|
||||||
parsed = new URL(url)
|
parsedUrl = new URL(url)
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: 'Invalid URL' }, 400)
|
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)
|
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')) {
|
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)
|
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 selectedType = (type && challengeTypes[type]) ? type : 'speed_blitz'
|
||||||
const ct = challengeTypes[selectedType]
|
const ct = challengeTypes[selectedType] || challengeTypes['speed_blitz']
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
fight_id: 'test_000000',
|
fight_id: 'test_000000',
|
||||||
|
|||||||
Reference in New Issue
Block a user