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
+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', () => {