diff --git a/server/src/engine/answers-edge.test.ts b/server/src/engine/answers-edge.test.ts index 7b120ad..2c87634 100644 --- a/server/src/engine/answers-edge.test.ts +++ b/server/src/engine/answers-edge.test.ts @@ -95,3 +95,90 @@ describe('checkAnswer edge cases', () => { expect(checkAnswer('FALSE', ['false'])).toBeGreaterThanOrEqual(0.9) }) }) + +describe('checkAnswer adversarial profiling — target <1ms per check', () => { + const TARGET_MS = 1 + + it('2000-char response', () => { + const longAnswer = 'x'.repeat(2000) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin', '42', 'Satoshi Nakamoto']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('2000-char response containing the answer buried deep', () => { + const longAnswer = 'z'.repeat(1900) + ' Bitcoin ' + 'z'.repeat(91) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('regex metacharacters in response — no backtracking', () => { + const regexBomb = '(a+)+$'.repeat(200) + '.*?.*?.*?' + '['.repeat(100) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(regexBomb, ['42', 'Bitcoin']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('regex metacharacters in accepted answer — escaped safely', () => { + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer('42', ['(a+)+$', '.*+', '[test]']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('unicode heavy response — CJK, emoji, combining chars', () => { + const unicode = '比特币₿🚀'.repeat(300) + ' Bitcoin ' + '漢字'.repeat(100) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(unicode, ['Bitcoin', '比特币']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('unicode combining characters and diacritics', () => { + // Zalgo text: base char + many combining marks + const zalgo = 'B' + '\u0300\u0301\u0302\u0303\u0304'.repeat(50) + 'itcoin' + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(zalgo, ['Bitcoin']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('many accepted answers (20) with long response', () => { + const answers = Array.from({ length: 20 }, (_, i) => `answer_variant_${i}_satoshi`) + const response = 'a'.repeat(1000) + ' answer_variant_19_satoshi ' + 'b'.repeat(1000) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(response, answers) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('pathological whitespace — tabs, newlines, mixed', () => { + const ws = '\t\n\r '.repeat(500) + 'Bitcoin' + ' \t\n'.repeat(500) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(ws, ['Bitcoin']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('numeric answer in huge response — boundary regex safe', () => { + // Tests the dynamic RegExp(numStr) path with number buried in text + const response = 'word '.repeat(400) + '21000000' + ' word'.repeat(400) + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(response, ['21000000']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) + + it('contraction-heavy 2000-char response', () => { + const contractions = "can't don't won't doesn't isn't aren't wasn't weren't hasn't haven't hadn't couldn't shouldn't wouldn't it's that's they're we're you're " + const response = contractions.repeat(15) // ~2000 chars + const start = performance.now() + for (let i = 0; i < 100; i++) checkAnswer(response, ['cannot', 'will not', 'they are']) + const avg = (performance.now() - start) / 100 + expect(avg).toBeLessThan(TARGET_MS) + }) +}) diff --git a/server/src/lib/validators.test.ts b/server/src/lib/validators.test.ts index ab249d6..faaae3b 100644 --- a/server/src/lib/validators.test.ts +++ b/server/src/lib/validators.test.ts @@ -24,6 +24,7 @@ import { startTournamentSchema, joinRankedSchema, testWebhookSchema, + sanitizeError, } from './validators.js' // --- Primitive schemas --- @@ -267,6 +268,68 @@ describe('joinRankedSchema', () => { }) }) +// --- 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', () => { diff --git a/server/src/lib/validators.ts b/server/src/lib/validators.ts index b633f1b..d53a703 100644 --- a/server/src/lib/validators.ts +++ b/server/src/lib/validators.ts @@ -143,6 +143,21 @@ export const testWebhookSchema = z.object({ // --- Error formatting helper --- +/** Patterns that indicate internal details that should not be exposed to clients */ +const UNSAFE_PATTERNS = /\.(ts|js|mjs)(:|$|\s|\))|\/src\/|\/node_modules\/|at\s+(Object|Module|Function|async)[\s.]|SQLITE_|ENOENT|ECONNREFUSED|EACCES|EPERM|errno|\/Users\/|\/home\/|\\x[0-9a-f]/i + +/** + * Sanitize an error message before returning it to the client. + * Strips internal details like file paths, stack fragments, and system errors. + * Returns the original message if it appears safe, or the fallback otherwise. + */ +export function sanitizeError(err: unknown, fallback: string): string { + if (!(err instanceof Error)) return fallback + const msg = err.message + if (!msg || UNSAFE_PATTERNS.test(msg)) return fallback + return msg +} + /** Map Zod validation errors to user-friendly messages by field name */ export function formatZodError( error: z.ZodError, diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index c7315a2..4b3beac 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -4,6 +4,7 @@ import { eq, desc, sql, count } from 'drizzle-orm' import { getActiveSSECount } from './fights.js' import { createBackup } from '../engine/backup.js' import { isCreatorPubkey } from '../lib/constants.js' +import { sanitizeError } from '../lib/validators.js' const startTime = Date.now() @@ -115,7 +116,7 @@ adminRouter.get('/backup', (c) => { const path = createBackup() return c.json({ ok: true, path }) } catch (err: unknown) { - const msg = err instanceof Error ? err.message : 'Backup failed' + const msg = sanitizeError(err, 'Backup failed') return c.json({ error: msg }, 500) } }) diff --git a/server/src/routes/error-handling.test.ts b/server/src/routes/error-handling.test.ts index d31100f..5a859dd 100644 --- a/server/src/routes/error-handling.test.ts +++ b/server/src/routes/error-handling.test.ts @@ -94,4 +94,22 @@ describe('global error handler', () => { expect(content).not.toMatch(/c\.json\([^)]*error\.stack/) } }) + + it('route catch blocks use sanitizeError instead of raw err.message', async () => { + const fs = await import('fs') + const path = await import('path') + const routesDir = path.resolve(import.meta.dirname || '.', '.') + // Files that handle errors returned to clients (excluding webhook test tools: bots.ts, docs.ts) + const checkedFiles = ['payments.ts', 'queue.ts', 'fights.ts', 'admin.ts', 'tournaments.ts'] + + for (const file of checkedFiles) { + const filePath = path.join(routesDir, file) + if (!fs.existsSync(filePath)) continue + const content = fs.readFileSync(filePath, 'utf-8') + // Should not have the old pattern: err instanceof Error ? err.message : '...' + // in the context of returning to c.json (route responses) + const unsafePattern = /c\.json\(\{[^}]*err\s*(instanceof\s+Error\s*\?\s*err\.message|\.message)/ + expect(content, `${file} should use sanitizeError, not raw err.message in responses`).not.toMatch(unsafePattern) + } + }) }) diff --git a/server/src/routes/fights.ts b/server/src/routes/fights.ts index 30111bb..24ee767 100644 --- a/server/src/routes/fights.ts +++ b/server/src/routes/fights.ts @@ -13,7 +13,7 @@ import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../ import { getPendingPollChallenge, submitPollResponse, isPollingBot } from '../engine/poll-responses.js' import { authenticateBot } from '../middleware/bot-auth.js' import { checkAnswer } from '../engine/answers.js' -import { respondSchema, reactSchema } from '../lib/validators.js' +import { respondSchema, reactSchema, sanitizeError } from '../lib/validators.js' const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id) @@ -250,7 +250,7 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => { try { fightId = await runFightAsync(botId, opponent.id) } catch (err) { - const msg = err instanceof Error ? err.message : 'Fight failed to start' + const msg = sanitizeError(err, 'Fight failed to start') return c.json({ error: msg }, 400) } @@ -304,7 +304,7 @@ fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => { const overrides = isPollingBot(bot.webhookUrl) ? { botAWebhookUrl: 'http://human.local/' } : undefined fightId = await runFightAsync(botId, opponent.id, 'free', overrides) } catch (err) { - const msg = err instanceof Error ? err.message : 'Fight failed to start' + const msg = sanitizeError(err, 'Fight failed to start') return c.json({ error: msg }, 400) } diff --git a/server/src/routes/payments.ts b/server/src/routes/payments.ts index 3db6f12..8e44e76 100644 --- a/server/src/routes/payments.ts +++ b/server/src/routes/payments.ts @@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm' import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js' import { encrypt, decrypt } from '../engine/crypto.js' import { rateLimit } from '../middleware/rate-limit.js' -import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError } from '../lib/validators.js' +import { connectWalletSchema, createInvoiceSchema, submitCashuSchema, disconnectWalletSchema, zapSchema, formatZodError, sanitizeError } from '../lib/validators.js' export const paymentsRouter = new Hono() @@ -103,7 +103,7 @@ paymentsRouter.post('/create-invoice', rateLimit(60_000, 10), async (c) => { const result = await createEntryInvoice(botId) return c.json(result) } catch (err) { - const message = err instanceof Error ? err.message : 'Invoice creation failed' + const message = sanitizeError(err, 'Invoice creation failed') return c.json({ error: message }, 500) } }) @@ -118,7 +118,7 @@ paymentsRouter.get('/check/:paymentId', rateLimit(60_000, 30), async (c) => { const status = await checkPaymentStatus(paymentId) return c.json({ status }) } catch (err) { - const message = err instanceof Error ? err.message : 'Status check failed' + const message = sanitizeError(err, 'Status check failed') return c.json({ error: message }, 500) } }) @@ -194,7 +194,7 @@ paymentsRouter.post('/submit-cashu', async (c) => { status: result.valid ? 'confirmed' : 'failed', }) } catch (err) { - const message = err instanceof Error ? err.message : 'Cashu redemption failed' + const message = sanitizeError(err, 'Cashu redemption failed') return c.json({ error: message }, 500) } }) diff --git a/server/src/routes/queue.ts b/server/src/routes/queue.ts index 73a71b6..82da57f 100644 --- a/server/src/routes/queue.ts +++ b/server/src/routes/queue.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm' import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js' import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js' import { rateLimit } from '../middleware/rate-limit.js' -import { joinRankedSchema } from '../lib/validators.js' +import { joinRankedSchema, sanitizeError } from '../lib/validators.js' export const queueRouter = new Hono() @@ -33,8 +33,10 @@ queueRouter.post('/join/:botId', async (c) => { const fightId = await joinQueue(botId) return c.json({ fightId, message: 'Matched! Fight starting.' }) } catch (err: any) { - const message = err instanceof Error ? err.message : 'Queue error' - const status = message.includes('already in a fight') ? 409 : 500 + const raw = err instanceof Error ? err.message : '' + const isConflict = raw.includes('already in a fight') + const message = isConflict ? raw : sanitizeError(err, 'Queue error') + const status = isConflict ? 409 : 500 return c.json({ error: message, fightId: err?.fightId || undefined }, status) } }) @@ -76,7 +78,7 @@ queueRouter.post('/join-ranked/:botId', async (c) => { const fightId = await joinRankedQueue(botId, paymentId) return c.json({ fightId, message: 'Ranked match found! Fight starting.' }) } catch (err) { - const message = err instanceof Error ? err.message : 'Ranked queue error' + const message = sanitizeError(err, 'Ranked queue error') return c.json({ error: message }, 500) } })