fix: sanitize error responses to prevent internal detail leakage

Add sanitizeError() helper that strips file paths, stack traces, SQLite
errors, and system errors from messages before returning them to clients.
Applied to all route-level catch blocks in payments, queue, fights, and
admin routes. Includes 12 tests for the sanitizer and static analysis
test verifying no route files leak raw err.message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:46:38 +00:00
co-authored by Claude Opus 4.6
parent 1b1f9eb2d7
commit 9b0d251d1c
8 changed files with 198 additions and 12 deletions
+87
View File
@@ -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)
})
})
+63
View File
@@ -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', () => {
+15
View File
@@ -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,
+2 -1
View File
@@ -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)
}
})
+18
View File
@@ -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)
}
})
})
+3 -3
View File
@@ -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)
}
+4 -4
View File
@@ -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)
}
})
+6 -4
View File
@@ -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)
}
})