Files
botfights/server/src/lib/validators.test.ts
T
DorianandClaude f5f57e60d9 fix(security): POST /api/auth/update trusted a client-supplied pubkey (IDOR)
Found live while wiring the "existing bot" AI-config settings UI: this
route parsed `pubkey` from the request body and used it directly to
select which bot row to update, with no check that it matched the
caller's actual authenticated identity. Any unauthenticated caller
could POST an arbitrary victim's pubkey plus a malicious webhookUrl,
profilePicUrl, or customization payload and silently hijack that bot
(e.g. redirect its webhook to an attacker-controlled endpoint).

Contrast with GET /me and POST /regenerate-secret, which both
correctly derive pubkey from the verified JWT via
extractPubkeyFromAuth and never trust a client-claimed identity — this
was the one route that didn't follow that pattern.

Fixed by deriving pubkey from the JWT exclusively; updateBotSchema no
longer declares a pubkey field at all (was the only schema-level
signal that the vulnerable code path existed). Frontend callers
updated to stop sending a pubkey they no longer need. Added a
regression suite (auth-update.test.ts) covering: 401 with no/garbage
auth, hijack-attempt-via-body-pubkey now 404s and leaves the victim's
row untouched, and legitimate self-updates still work when the body
happens to carry an unrelated pubkey field (ignored, not trusted).

Full server suite: 829/829 passing. tsc --noEmit clean (server +
frontend).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 12:06:36 -04:00

422 lines
18 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
pubkeySchema,
botNameSchema,
satsSchema,
idSchema,
httpUrlSchema,
loginSchema,
registerSchema,
registerHumanSchema,
updateBotSchema,
respondSchema,
reactSchema,
placeBetSchema,
depositSchema,
withdrawSchema,
connectWalletSchema,
createInvoiceSchema,
submitCashuSchema,
zapSchema,
disconnectWalletSchema,
createTournamentSchema,
joinTournamentSchema,
startTournamentSchema,
joinRankedSchema,
testWebhookSchema,
sanitizeError,
} from './validators.js'
// --- Primitive schemas ---
describe('pubkeySchema', () => {
const valid = 'a'.repeat(64)
it('accepts valid 64-char hex', () => { expect(pubkeySchema.safeParse(valid).success).toBe(true) })
it('rejects too short', () => { expect(pubkeySchema.safeParse('abc').success).toBe(false) })
it('rejects too long', () => { expect(pubkeySchema.safeParse('a'.repeat(65)).success).toBe(false) })
it('rejects non-hex', () => { expect(pubkeySchema.safeParse('g'.repeat(64)).success).toBe(false) })
it('rejects uppercase', () => { expect(pubkeySchema.safeParse('A'.repeat(64)).success).toBe(false) })
it('rejects empty', () => { expect(pubkeySchema.safeParse('').success).toBe(false) })
it('rejects number', () => { expect(pubkeySchema.safeParse(12345).success).toBe(false) })
it('rejects null', () => { expect(pubkeySchema.safeParse(null).success).toBe(false) })
})
describe('botNameSchema', () => {
it('accepts valid names', () => {
expect(botNameSchema.safeParse('ab').success).toBe(true)
expect(botNameSchema.safeParse('test-bot_1').success).toBe(true)
expect(botNameSchema.safeParse('ABCDEFGHIJKL').success).toBe(true) // 12 chars
})
it('rejects too short', () => { expect(botNameSchema.safeParse('a').success).toBe(false) })
it('rejects too long', () => { expect(botNameSchema.safeParse('a'.repeat(13)).success).toBe(false) })
it('rejects special chars', () => {
expect(botNameSchema.safeParse('bot name').success).toBe(false) // spaces
expect(botNameSchema.safeParse('bot@name').success).toBe(false)
expect(botNameSchema.safeParse('<script>').success).toBe(false)
})
it('rejects empty', () => { expect(botNameSchema.safeParse('').success).toBe(false) })
})
describe('satsSchema', () => {
it('accepts valid amounts', () => {
expect(satsSchema.safeParse(1).success).toBe(true)
expect(satsSchema.safeParse(1_000_000).success).toBe(true)
})
it('rejects zero', () => { expect(satsSchema.safeParse(0).success).toBe(false) })
it('rejects negative', () => { expect(satsSchema.safeParse(-1).success).toBe(false) })
it('rejects over max', () => { expect(satsSchema.safeParse(1_000_001).success).toBe(false) })
it('rejects non-integer', () => { expect(satsSchema.safeParse(1.5).success).toBe(false) })
it('rejects string', () => { expect(satsSchema.safeParse('100').success).toBe(false) })
})
describe('idSchema', () => {
it('accepts valid IDs', () => {
expect(idSchema.safeParse('abc123').success).toBe(true)
expect(idSchema.safeParse('x'.repeat(24)).success).toBe(true)
})
it('rejects empty', () => { expect(idSchema.safeParse('').success).toBe(false) })
it('rejects too long', () => { expect(idSchema.safeParse('x'.repeat(25)).success).toBe(false) })
})
describe('httpUrlSchema', () => {
it('accepts http/https URLs', () => {
expect(httpUrlSchema.safeParse('https://example.com').success).toBe(true)
expect(httpUrlSchema.safeParse('http://api.bot.dev/hook').success).toBe(true)
})
it('rejects file:// URLs', () => { expect(httpUrlSchema.safeParse('file:///etc/passwd').success).toBe(false) })
it('rejects ftp:// URLs', () => { expect(httpUrlSchema.safeParse('ftp://files.example.com').success).toBe(false) })
it('rejects non-URLs', () => { expect(httpUrlSchema.safeParse('not a url').success).toBe(false) })
it('rejects javascript: URLs', () => { expect(httpUrlSchema.safeParse('javascript:alert(1)').success).toBe(false) })
})
// --- Auth schemas ---
describe('loginSchema', () => {
const validPk = 'a'.repeat(64)
it('accepts valid login', () => { expect(loginSchema.safeParse({ pubkey: validPk }).success).toBe(true) })
it('rejects missing pubkey', () => { expect(loginSchema.safeParse({}).success).toBe(false) })
it('rejects bad pubkey', () => { expect(loginSchema.safeParse({ pubkey: 'short' }).success).toBe(false) })
})
describe('registerSchema', () => {
const base = { pubkey: 'a'.repeat(64), name: 'testbot' }
it('accepts minimal registration', () => { expect(registerSchema.safeParse(base).success).toBe(true) })
it('accepts full registration', () => {
expect(registerSchema.safeParse({
...base,
webhookUrl: 'https://mybot.com/hook',
archetype: 'warrior',
profilePicUrl: 'https://img.com/pic.png',
customization: { color: 'red' },
}).success).toBe(true)
})
it('accepts empty webhook (poll mode)', () => {
expect(registerSchema.safeParse({ ...base, webhookUrl: '' }).success).toBe(true)
})
it('rejects bad name', () => {
expect(registerSchema.safeParse({ ...base, name: 'x' }).success).toBe(false) // too short
expect(registerSchema.safeParse({ ...base, name: 'x'.repeat(13) }).success).toBe(false) // too long
expect(registerSchema.safeParse({ ...base, name: 'bad name!' }).success).toBe(false) // special chars
})
it('rejects bad pubkey', () => {
expect(registerSchema.safeParse({ ...base, pubkey: 'bad' }).success).toBe(false)
})
})
describe('registerHumanSchema', () => {
const base = { pubkey: 'a'.repeat(64), name: 'human01' }
it('accepts valid human registration', () => { expect(registerHumanSchema.safeParse(base).success).toBe(true) })
it('accepts with avatar seed', () => {
expect(registerHumanSchema.safeParse({ ...base, avatarSeed: 'seed123' }).success).toBe(true)
})
})
describe('updateBotSchema', () => {
const base = {}
it('accepts empty body (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
it('accepts webhook update', () => {
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
})
it('rejects file:// webhook', () => {
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
})
// SECURITY REGRESSION: pubkey must never be a schema field here. POST
// /api/auth/update derives identity from the verified JWT
// (extractPubkeyFromAuth), not from client body — see server/src/routes/auth.ts.
// A pubkey field in this schema previously let an unauthenticated caller
// claim any bot as their own and hijack its webhook/customization.
it('does not declare a pubkey field (identity comes from the JWT, not the body)', () => {
expect('pubkey' in updateBotSchema.shape).toBe(false)
})
})
// --- Fight schemas ---
describe('respondSchema', () => {
it('accepts valid response', () => {
expect(respondSchema.safeParse({ answer: 'hello' }).success).toBe(true)
})
it('accepts with trash talk', () => {
expect(respondSchema.safeParse({ answer: 'hello', trashTalk: 'rekt' }).success).toBe(true)
})
it('rejects answer > 2000 chars', () => {
expect(respondSchema.safeParse({ answer: 'x'.repeat(2001) }).success).toBe(false)
})
it('rejects trash talk > 200 chars', () => {
expect(respondSchema.safeParse({ answer: 'ok', trashTalk: 'x'.repeat(201) }).success).toBe(false)
})
})
describe('reactSchema', () => {
it('accepts valid emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥' }).success).toBe(true) })
it('rejects empty emoji', () => { expect(reactSchema.safeParse({ emoji: '' }).success).toBe(false) })
it('rejects oversized emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥'.repeat(5) }).success).toBe(false) })
})
// --- Bet schemas ---
describe('placeBetSchema', () => {
const valid = {
fightId: 'fight123',
pubkey: 'a'.repeat(64),
botId: 'bot456',
amountSats: 1000,
cashuToken: 'cashuAey...',
}
it('accepts valid bet', () => { expect(placeBetSchema.safeParse(valid).success).toBe(true) })
it('rejects zero amount', () => {
expect(placeBetSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false)
})
it('rejects negative amount', () => {
expect(placeBetSchema.safeParse({ ...valid, amountSats: -100 }).success).toBe(false)
})
it('rejects over-max amount', () => {
expect(placeBetSchema.safeParse({ ...valid, amountSats: 999999999 }).success).toBe(false)
})
it('rejects non-integer amount', () => {
expect(placeBetSchema.safeParse({ ...valid, amountSats: 1.5 }).success).toBe(false)
})
it('rejects missing fields', () => {
expect(placeBetSchema.safeParse({ fightId: 'x' }).success).toBe(false)
})
it('rejects empty cashu token', () => {
expect(placeBetSchema.safeParse({ ...valid, cashuToken: '' }).success).toBe(false)
})
})
describe('depositSchema', () => {
it('accepts valid deposit', () => {
expect(depositSchema.safeParse({ amountSats: 500, pubkey: 'a'.repeat(64) }).success).toBe(true)
})
it('rejects under minimum (100)', () => {
expect(depositSchema.safeParse({ amountSats: 50, pubkey: 'a'.repeat(64) }).success).toBe(false)
})
})
describe('withdrawSchema', () => {
it('accepts valid withdrawal', () => {
expect(withdrawSchema.safeParse({ cashuToken: 'tok', bolt11: 'lnbc...' }).success).toBe(true)
})
it('rejects missing bolt11', () => {
expect(withdrawSchema.safeParse({ cashuToken: 'tok' }).success).toBe(false)
})
})
// --- Payment schemas ---
describe('connectWalletSchema', () => {
const valid = { pubkey: 'a'.repeat(64), method: 'nwc' as const, connectionData: 'nostr+walletconnect://...' }
it('accepts valid connection', () => { expect(connectWalletSchema.safeParse(valid).success).toBe(true) })
it('rejects invalid method', () => {
expect(connectWalletSchema.safeParse({ ...valid, method: 'paypal' }).success).toBe(false)
})
it('rejects empty connectionData', () => {
expect(connectWalletSchema.safeParse({ ...valid, connectionData: '' }).success).toBe(false)
})
})
describe('zapSchema', () => {
const valid = { winnerId: 'bot1', fightId: 'fight1', amountSats: 100 }
it('accepts valid zap', () => { expect(zapSchema.safeParse(valid).success).toBe(true) })
it('rejects zero sats', () => { expect(zapSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false) })
})
// --- Tournament schemas ---
describe('createTournamentSchema', () => {
const valid = { pubkey: 'a'.repeat(64), name: 'Lightning Cup' }
it('accepts minimal tournament', () => { expect(createTournamentSchema.safeParse(valid).success).toBe(true) })
it('accepts full tournament', () => {
expect(createTournamentSchema.safeParse({
...valid, format: 'round_robin', size: 16, entrySats: 500,
}).success).toBe(true)
})
it('rejects invalid size', () => {
expect(createTournamentSchema.safeParse({ ...valid, size: 64 }).success).toBe(false)
})
it('rejects empty name', () => {
expect(createTournamentSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
})
it('rejects name > 100 chars', () => {
expect(createTournamentSchema.safeParse({ ...valid, name: 'x'.repeat(101) }).success).toBe(false)
})
})
// --- Queue schemas ---
describe('joinRankedSchema', () => {
it('accepts valid join', () => {
expect(joinRankedSchema.safeParse({ paymentId: 'pay123' }).success).toBe(true)
})
it('accepts with pubkey', () => {
expect(joinRankedSchema.safeParse({ paymentId: 'pay123', pubkey: 'a'.repeat(64) }).success).toBe(true)
})
it('rejects missing paymentId', () => {
expect(joinRankedSchema.safeParse({}).success).toBe(false)
})
})
// --- sanitizeError ---
describe('sanitizeError', () => {
it('returns fallback for non-Error values', () => {
expect(sanitizeError('string error', 'fallback')).toBe('fallback')
expect(sanitizeError(null, 'fallback')).toBe('fallback')
expect(sanitizeError(undefined, 'fallback')).toBe('fallback')
expect(sanitizeError(42, 'fallback')).toBe('fallback')
})
it('passes through safe error messages', () => {
expect(sanitizeError(new Error('Bot not found'), 'fallback')).toBe('Bot not found')
expect(sanitizeError(new Error('Payment already confirmed'), 'fallback')).toBe('Payment already confirmed')
expect(sanitizeError(new Error('already in a fight'), 'fallback')).toBe('already in a fight')
expect(sanitizeError(new Error('Invoice creation failed'), 'fallback')).toBe('Invoice creation failed')
})
it('strips messages with TypeScript file paths', () => {
expect(sanitizeError(new Error('TypeError at /src/engine/payments.ts:42'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('Cannot read property of null at file.ts:10'), 'fallback')).toBe('fallback')
})
it('strips messages with JavaScript file paths', () => {
expect(sanitizeError(new Error('ReferenceError in module.js:5'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('Error in handler.mjs '), 'fallback')).toBe('fallback')
})
it('strips messages with /src/ paths', () => {
expect(sanitizeError(new Error('Failed to load /src/config/keys'), 'fallback')).toBe('fallback')
})
it('strips messages with node_modules paths', () => {
expect(sanitizeError(new Error('Error in /node_modules/drizzle-orm/dist/index.js'), 'fallback')).toBe('fallback')
})
it('strips messages with stack trace fragments', () => {
expect(sanitizeError(new Error('at Object.runInContext (vm.js:130)'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('at Module._compile (internal/modules)'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('at async Router.handle'), 'fallback')).toBe('fallback')
})
it('strips messages with SQLite errors', () => {
expect(sanitizeError(new Error('SQLITE_CONSTRAINT: UNIQUE constraint failed'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('SQLITE_ERROR: no such table: users'), 'fallback')).toBe('fallback')
})
it('strips messages with system errors', () => {
expect(sanitizeError(new Error('ENOENT: no such file or directory'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('ECONNREFUSED 127.0.0.1:5432'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('EACCES: permission denied'), 'fallback')).toBe('fallback')
})
it('strips messages with absolute paths', () => {
expect(sanitizeError(new Error('Cannot open /Users/deploy/app/db.sqlite'), 'fallback')).toBe('fallback')
expect(sanitizeError(new Error('File not found: /home/app/config.json'), 'fallback')).toBe('fallback')
})
it('returns fallback for empty message', () => {
expect(sanitizeError(new Error(''), 'fallback')).toBe('fallback')
})
})
// --- Attack inputs ---
describe('attack inputs', () => {
it('rejects SQL injection in pubkey', () => {
expect(pubkeySchema.safeParse("' OR 1=1 --").success).toBe(false)
})
it('rejects XSS in bot name', () => {
expect(botNameSchema.safeParse('<img onerror=alert(1)>').success).toBe(false)
})
it('rejects prototype pollution in customization keys', () => {
// The schema allows unknown keys, but Zod strips __proto__ in strict mode
const result = registerSchema.safeParse({
pubkey: 'a'.repeat(64),
name: 'test01',
customization: { __proto__: { admin: true } },
})
// This should still parse (customization is z.record(z.unknown()))
// The actual protection is that we never spread customization into objects unsafely
expect(result.success).toBe(true)
})
it('rejects absurdly long answer', () => {
expect(respondSchema.safeParse({ answer: 'x'.repeat(10000) }).success).toBe(false)
})
it('rejects negative sats in deposit', () => {
expect(depositSchema.safeParse({ amountSats: -1, pubkey: 'a'.repeat(64) }).success).toBe(false)
})
it('rejects data: URL in webhook', () => {
expect(httpUrlSchema.safeParse('data:text/html,<h1>hi</h1>').success).toBe(false)
})
})
describe('auth & registration edge cases', () => {
it('unicode bot name rejected', () => {
expect(botNameSchema.safeParse('ビットコイン').success).toBe(false)
expect(botNameSchema.safeParse('café').success).toBe(false)
expect(botNameSchema.safeParse('bot🚀').success).toBe(false)
expect(botNameSchema.safeParse('böt').success).toBe(false)
})
it('bot name with spaces rejected', () => {
expect(botNameSchema.safeParse('my bot').success).toBe(false)
})
it('bot name with special chars rejected', () => {
expect(botNameSchema.safeParse('bot!@#').success).toBe(false)
expect(botNameSchema.safeParse('bot.name').success).toBe(false)
expect(botNameSchema.safeParse('bot/name').success).toBe(false)
})
it('valid bot names accepted', () => {
expect(botNameSchema.safeParse('SatoshiBot').success).toBe(true)
expect(botNameSchema.safeParse('bot-1').success).toBe(true)
expect(botNameSchema.safeParse('bot_2').success).toBe(true)
expect(botNameSchema.safeParse('A1').success).toBe(true)
})
it('two bots with same webhook URL: both pass validation (uniqueness enforced at DB level)', () => {
const pub1 = 'a'.repeat(64)
const pub2 = 'b'.repeat(64)
const sharedUrl = 'https://example.com/webhook'
const r1 = registerSchema.safeParse({ pubkey: pub1, name: 'Bot1', webhookUrl: sharedUrl })
const r2 = registerSchema.safeParse({ pubkey: pub2, name: 'Bot2', webhookUrl: sharedUrl })
expect(r1.success).toBe(true)
expect(r2.success).toBe(true)
// Note: same webhook URL is allowed — uniqueness is on name, not URL
})
it('pubkey must be exactly 64 hex chars', () => {
expect(pubkeySchema.safeParse('a'.repeat(63)).success).toBe(false) // too short
expect(pubkeySchema.safeParse('a'.repeat(65)).success).toBe(false) // too long
expect(pubkeySchema.safeParse('g'.repeat(64)).success).toBe(false) // non-hex
expect(pubkeySchema.safeParse('A'.repeat(64)).success).toBe(false) // uppercase (not lowercase hex)
expect(pubkeySchema.safeParse('a'.repeat(64)).success).toBe(true) // valid
})
it('register rejects missing required fields', () => {
expect(registerSchema.safeParse({}).success).toBe(false)
expect(registerSchema.safeParse({ pubkey: 'a'.repeat(64) }).success).toBe(false) // missing name
expect(registerSchema.safeParse({ name: 'Bot1' }).success).toBe(false) // missing pubkey
})
})