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:
co-authored by
Claude Opus 4.6
parent
1b1f9eb2d7
commit
9b0d251d1c
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user