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)
})
})