From e7a8ea6b7f920ca0144d20443c674ce55c928584 Mon Sep 17 00:00:00 2001 From: timiturn3r Date: Mon, 28 Sep 2026 09:26:35 +0100 Subject: [PATCH] fix(backend): sanitize Prisma database errors in API responses (#1516) - Intercept Prisma errors (PrismaClientKnownRequestError, PrismaClientValidationError, PrismaClientUnknownRequestError) and map them to generic, safe error codes (DUPLICATE_ENTRY, RESOURCE_NOT_FOUND, FOREIGN_KEY_VIOLATION, etc.). - Mask database table names, column layouts, constraint targets, and raw SQL queries from 4xx/5xx API response bodies. - Preserve full error context, meta, and stack traces internally in Winston logs. - Add comprehensive unit tests in backend/tests/api-error.test.ts and backend/tests/error.middleware.test.ts. Closes #1516 --- backend/src/lib/api-error.ts | 258 +++++++++++++++- backend/src/middleware/error.middleware.ts | 37 +-- backend/tests/api-error.test.ts | 337 +++++++++++++++++++++ backend/tests/error.middleware.test.ts | 180 ++++++++++- 4 files changed, 787 insertions(+), 25 deletions(-) create mode 100644 backend/tests/api-error.test.ts diff --git a/backend/src/lib/api-error.ts b/backend/src/lib/api-error.ts index 5508a62a..df619367 100644 --- a/backend/src/lib/api-error.ts +++ b/backend/src/lib/api-error.ts @@ -1,3 +1,5 @@ +import { Prisma } from '../generated/prisma/index.js'; + /** * Typed application error carrying an HTTP status and optional machine-readable * remediation hints. @@ -24,7 +26,261 @@ export class ApiError extends Error { this.code = code; if (details !== undefined) this.details = details; } + + get statusCode(): number { + return this.status; + } + + static fromPrismaError(err: unknown): ApiError | null { + return fromPrismaError(err); + } } -export const badRequest = (message: string, code?: string, details?: Record) => +export const badRequest = (message: string, code = 'BAD_REQUEST', details?: Record) => new ApiError(400, message, code, details); + +export const duplicateEntry = ( + message = 'A record with this unique value already exists.', + code = 'DUPLICATE_ENTRY', + details?: Record, +) => new ApiError(409, message, code, details); + +export const resourceNotFound = ( + message = 'The requested record was not found.', + code = 'RESOURCE_NOT_FOUND', + details?: Record, +) => new ApiError(404, message, code, details); + +export const foreignKeyViolation = ( + message = 'Related resource constraint violation.', + code = 'FOREIGN_KEY_VIOLATION', + details?: Record, +) => new ApiError(409, message, code, details); + +export const internal = ( + message = 'A technical error occurred. Please try again later.', + code = 'INTERNAL_SERVER_ERROR', + details?: Record, +) => new ApiError(500, message, code, details); + +/** + * Check if an error appears to be an error originating from Prisma. + */ +export function isPrismaError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false; + } + + if ( + err instanceof Prisma.PrismaClientKnownRequestError || + err instanceof Prisma.PrismaClientUnknownRequestError || + err instanceof Prisma.PrismaClientRustPanicError || + err instanceof Prisma.PrismaClientInitializationError || + err instanceof Prisma.PrismaClientValidationError + ) { + return true; + } + + if (err instanceof Error) { + return ( + err.name.startsWith('PrismaClient') || + ('code' in err && + typeof (err as any).code === 'string' && + (err as any).code.startsWith('P') && + 'clientVersion' in err) + ); + } + + return false; +} + +/** + * Maps Prisma database errors to generic, safe ApiErrors to prevent schema disclosure. + * + * Ensures raw SQL, table names, constraint details, and column layouts never appear + * in client responses. + */ +export function fromPrismaError(err: unknown): ApiError | null { + if (!err || typeof err !== 'object') { + return null; + } + + // Handle PrismaClientKnownRequestError + const isKnown = + err instanceof Prisma.PrismaClientKnownRequestError || + (err instanceof Error && + (err.name === 'PrismaClientKnownRequestError' || + ('code' in err && + typeof (err as any).code === 'string' && + (err as any).code.startsWith('P')))); + + if (isKnown) { + const code = (err as any).code as string; + switch (code) { + case 'P2002': + // Unique constraint violation - NEVER leak meta.target, column names, or table names + return new ApiError( + 409, + 'A record with this unique value already exists.', + 'DUPLICATE_ENTRY', + ); + + case 'P2001': + case 'P2015': + case 'P2018': + case 'P2025': + // Record not found - NEVER leak model name or search condition + return new ApiError( + 404, + 'The requested record was not found.', + 'RESOURCE_NOT_FOUND', + ); + + case 'P2003': + // Foreign key violation - NEVER leak meta.field_name or relation + return new ApiError( + 409, + 'Related resource constraint violation.', + 'FOREIGN_KEY_VIOLATION', + ); + + case 'P2000': + // Value too long for column + return new ApiError( + 400, + 'A provided value exceeds the allowable limit.', + 'VALUE_OUT_OF_RANGE', + ); + + case 'P2004': + // Database constraint failed + return new ApiError( + 400, + 'A database constraint was violated.', + 'CONSTRAINT_FAILED', + ); + + case 'P2005': + case 'P2006': + // Invalid data for field type + return new ApiError( + 400, + 'Invalid data provided for one or more fields.', + 'INVALID_INPUT', + ); + + case 'P2011': + case 'P2012': + case 'P2013': + // Null or missing required field + return new ApiError( + 400, + 'A required field was not provided.', + 'MISSING_REQUIRED_FIELD', + ); + + case 'P2014': + case 'P2017': + // Relation violation + return new ApiError( + 409, + 'The requested operation violates a relation constraint.', + 'RELATION_VIOLATION', + ); + + case 'P2016': + // Query interpretation error + return new ApiError( + 400, + 'Invalid query parameters.', + 'INVALID_QUERY', + ); + + case 'P2021': + case 'P2022': + // Table or column does not exist + return new ApiError( + 500, + 'A technical error occurred. Please try again later.', + 'INTERNAL_SERVER_ERROR', + ); + + default: + // Any other Prisma request errors (P2xxx, etc.) + return new ApiError( + 400, + 'A database error occurred.', + 'DATABASE_ERROR', + ); + } + } + + // Handle PrismaClientValidationError (invalid arguments, invalid invocations) + if ( + err instanceof Prisma.PrismaClientValidationError || + (err instanceof Error && err.name === 'PrismaClientValidationError') + ) { + return new ApiError(400, 'Invalid request data.', 'VALIDATION_ERROR'); + } + + // Handle PrismaClientUnknownRequestError, RustPanic, InitializationError + if ( + err instanceof Prisma.PrismaClientUnknownRequestError || + err instanceof Prisma.PrismaClientRustPanicError || + err instanceof Prisma.PrismaClientInitializationError || + (err instanceof Error && + (err.name === 'PrismaClientUnknownRequestError' || + err.name === 'PrismaClientRustPanicError' || + err.name === 'PrismaClientInitializationError')) + ) { + return new ApiError( + 500, + 'A technical error occurred. Please try again later.', + 'INTERNAL_SERVER_ERROR', + ); + } + + return null; +} + +/** + * Checks whether an error message contains raw SQL fragments, table names, or database internals. + */ +export function containsDatabaseDetails(message: string): boolean { + if (!message || typeof message !== 'string') return false; + + const patterns = [ + /\bselect\b[\s\S]*?\bfrom\b/i, + /\binsert\s+into\b/i, + /\bupdate\b[\s\S]*?\bset\b/i, + /\bdelete\s+from\b/i, + /\bcreate\s+table\b/i, + /\bdrop\s+table\b/i, + /\balter\s+table\b/i, + /\bprisma\b/i, + /\btable\s+["`']?[a-zA-Z0-9_]+["`']?/i, + /\bcolumn\s+["`']?[a-zA-Z0-9_]+["`']?/i, + /\brelation\s+["`']?[a-zA-Z0-9_]+["`']?/i, + /\bconstraint\s+["`']?[a-zA-Z0-9_]+["`']?/i, + /\bforeign\s+key\b/i, + /\bunique\s+constraint\b/i, + /\bnull\s+constraint\b/i, + /\bsyntax\s+error\s+at\s+or\s+near\b/i, + /\bdatabase\s+error\b/i, + /\bpg_\b/i, + ]; + + return patterns.some((re) => re.test(message)); +} + +/** + * Sanitizes any message destined for an API response to prevent raw SQL and table name leakage. + */ +export function sanitizeDatabaseErrorMessage(message: string, statusCode = 500): string { + if (containsDatabaseDetails(message)) { + return statusCode === 500 + ? 'A technical error occurred. Please try again later.' + : 'A database error occurred.'; + } + return message; +} diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts index 840e6650..a1e95a16 100644 --- a/backend/src/middleware/error.middleware.ts +++ b/backend/src/middleware/error.middleware.ts @@ -1,8 +1,8 @@ import type { Request, Response, NextFunction } from 'express'; -import { Prisma } from '../generated/prisma/index.js'; import { ZodError, type ZodIssue } from 'zod'; import logger from '../logger.js'; -import { ApiError, sendApiError } from '../types/api-error.js'; +import { ApiError as TypeApiError, sendApiError } from '../types/api-error.js'; +import { ApiError as LibApiError, fromPrismaError, sanitizeDatabaseErrorMessage } from '../lib/api-error.js'; /** * Global error handler middleware @@ -41,27 +41,30 @@ export const errorHandler = ( }))); } - if (err instanceof ApiError) { - return sendApiError(res, err.statusCode, err.code, err.message, err.details); + // Intercept Prisma database errors to prevent schema disclosure + const prismaError = fromPrismaError(err); + if (prismaError) { + return sendApiError(res, prismaError.status, prismaError.code, prismaError.message); } - // Handle Prisma Errors - if (err instanceof Prisma.PrismaClientKnownRequestError) { - // Unique constraint violation - if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2002') { - const target = ((err as Prisma.PrismaClientKnownRequestError).meta?.target as string[])?.join(', ') || 'field'; - return sendApiError(res, 409, 'CONFLICT', `Record with this ${target} already exists.`); - } - - // Record not found - if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2025') { - return sendApiError(res, 404, 'NOT_FOUND', 'The requested record was not found.'); - } + if ( + err instanceof TypeApiError || + err instanceof LibApiError || + (err instanceof Error && 'statusCode' in err && 'code' in err) || + (err instanceof Error && 'status' in err && 'code' in err) + ) { + const statusCode = (err as any).statusCode ?? (err as any).status ?? 500; + const code = (err as any).code || (statusCode === 500 ? 'INTERNAL_SERVER_ERROR' : 'REQUEST_ERROR'); + let message = (err as any).message || 'Request failed'; + message = sanitizeDatabaseErrorMessage(message, statusCode); + return sendApiError(res, statusCode, code, message, (err as any).details); } // Default Error const statusCode = (err instanceof Error && (err as any).status) || (err instanceof Error && (err as any).statusCode) || 500; - const message = statusCode === 500 ? 'A technical error occurred. Please try again later.' : (err instanceof Error ? err.message : 'Request failed'); + let message = statusCode === 500 ? 'A technical error occurred. Please try again later.' : (err instanceof Error ? err.message : 'Request failed'); + message = sanitizeDatabaseErrorMessage(message, statusCode); const code = statusCode === 500 ? 'INTERNAL_SERVER_ERROR' : 'REQUEST_ERROR'; return sendApiError(res, statusCode, code, message); }; + diff --git a/backend/tests/api-error.test.ts b/backend/tests/api-error.test.ts new file mode 100644 index 00000000..2aad43ff --- /dev/null +++ b/backend/tests/api-error.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect } from 'vitest'; +import { Prisma } from '../src/generated/prisma/index.js'; +import { + ApiError, + badRequest, + duplicateEntry, + resourceNotFound, + foreignKeyViolation, + internal, + fromPrismaError, + isPrismaError, + containsDatabaseDetails, + sanitizeDatabaseErrorMessage, +} from '../src/lib/api-error.js'; + +describe('ApiError and Prisma error sanitization', () => { + describe('ApiError class and factory functions', () => { + it('creates ApiError with default code and optional details', () => { + const err = new ApiError(400, 'Invalid request'); + expect(err.status).toBe(400); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('api_error'); + expect(err.message).toBe('Invalid request'); + expect(err.details).toBeUndefined(); + + const errWithDetails = new ApiError(422, 'Unprocessable', 'UNPROCESSABLE', { foo: 'bar' }); + expect(errWithDetails.details).toEqual({ foo: 'bar' }); + expect(errWithDetails.code).toBe('UNPROCESSABLE'); + }); + + it('creates badRequest error', () => { + const err = badRequest('Bad input', 'INVALID_PARAM'); + expect(err.status).toBe(400); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('INVALID_PARAM'); + expect(err.message).toBe('Bad input'); + }); + + it('creates duplicateEntry error', () => { + const err = duplicateEntry(); + expect(err.status).toBe(409); + expect(err.code).toBe('DUPLICATE_ENTRY'); + expect(err.message).toBe('A record with this unique value already exists.'); + }); + + it('creates resourceNotFound error', () => { + const err = resourceNotFound(); + expect(err.status).toBe(404); + expect(err.code).toBe('RESOURCE_NOT_FOUND'); + expect(err.message).toBe('The requested record was not found.'); + }); + + it('creates foreignKeyViolation error', () => { + const err = foreignKeyViolation(); + expect(err.status).toBe(409); + expect(err.code).toBe('FOREIGN_KEY_VIOLATION'); + expect(err.message).toBe('Related resource constraint violation.'); + }); + + it('creates internal error', () => { + const err = internal(); + expect(err.status).toBe(500); + expect(err.code).toBe('INTERNAL_SERVER_ERROR'); + expect(err.message).toBe('A technical error occurred. Please try again later.'); + }); + }); + + describe('isPrismaError', () => { + it('returns true for PrismaClientKnownRequestError', () => { + const err = new Prisma.PrismaClientKnownRequestError('Error', { + code: 'P2002', + clientVersion: '1.0', + }); + expect(isPrismaError(err)).toBe(true); + }); + + it('returns true for PrismaClientValidationError', () => { + const err = new Prisma.PrismaClientValidationError('Validation failed', { + clientVersion: '1.0', + }); + expect(isPrismaError(err)).toBe(true); + }); + + it('returns true for PrismaClientUnknownRequestError', () => { + const err = new Prisma.PrismaClientUnknownRequestError('Unknown DB error', { + clientVersion: '1.0', + }); + expect(isPrismaError(err)).toBe(true); + }); + + it('returns true for duck-typed Prisma error objects', () => { + const duckTyped = Object.assign(new Error('P2002 failed'), { + code: 'P2002', + clientVersion: '1.0', + }); + expect(isPrismaError(duckTyped)).toBe(true); + }); + + it('returns false for an Error with non-matching properties', () => { + const customErr = new Error('Custom'); + (customErr as any).code = 'CUSTOM_ERROR'; + expect(isPrismaError(customErr)).toBe(false); + }); + + it('returns false for normal Errors or non-objects', () => { + expect(isPrismaError(new Error('normal error'))).toBe(false); + expect(isPrismaError(null)).toBe(false); + expect(isPrismaError(undefined)).toBe(false); + expect(isPrismaError('string error')).toBe(false); + }); + }); + + describe('fromPrismaError', () => { + it('maps P2002 (unique constraint) to DUPLICATE_ENTRY (409) and masks table/column metadata', () => { + const err = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`publicKey`) table: `User`', + { + code: 'P2002', + clientVersion: '1.0', + meta: { target: ['User_publicKey_key', 'publicKey'] }, + }, + ); + + const apiErr = fromPrismaError(err); + expect(apiErr).not.toBeNull(); + expect(apiErr?.status).toBe(409); + expect(apiErr?.code).toBe('DUPLICATE_ENTRY'); + expect(apiErr?.message).toBe('A record with this unique value already exists.'); + expect(apiErr?.message).not.toContain('User'); + expect(apiErr?.message).not.toContain('publicKey'); + expect(apiErr?.details).toBeUndefined(); + }); + + it('maps P2025 and P2001 (not found) to RESOURCE_NOT_FOUND (404)', () => { + const err2025 = new Prisma.PrismaClientKnownRequestError( + 'Record to update not found in table Stream', + { + code: 'P2025', + clientVersion: '1.0', + meta: { cause: 'Stream with id 123 does not exist' }, + }, + ); + + const apiErr2025 = fromPrismaError(err2025); + expect(apiErr2025?.status).toBe(404); + expect(apiErr2025?.code).toBe('RESOURCE_NOT_FOUND'); + expect(apiErr2025?.message).toBe('The requested record was not found.'); + expect(apiErr2025?.message).not.toContain('Stream'); + + const err2001 = new Prisma.PrismaClientKnownRequestError( + 'Record in table Stream does not exist', + { code: 'P2001', clientVersion: '1.0' }, + ); + const apiErr2001 = fromPrismaError(err2001); + expect(apiErr2001?.status).toBe(404); + expect(apiErr2001?.code).toBe('RESOURCE_NOT_FOUND'); + }); + + it('maps P2003 (foreign key constraint) to FOREIGN_KEY_VIOLATION (409)', () => { + const err = new Prisma.PrismaClientKnownRequestError( + 'Foreign key constraint failed on the field: `userId` table: `Stream`', + { + code: 'P2003', + clientVersion: '1.0', + meta: { field_name: 'userId' }, + }, + ); + + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(409); + expect(apiErr?.code).toBe('FOREIGN_KEY_VIOLATION'); + expect(apiErr?.message).toBe('Related resource constraint violation.'); + expect(apiErr?.message).not.toContain('userId'); + expect(apiErr?.message).not.toContain('Stream'); + }); + + it('maps P2000 (value too long) to VALUE_OUT_OF_RANGE (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Value too long', { + code: 'P2000', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('VALUE_OUT_OF_RANGE'); + }); + + it('maps P2004 (database constraint failed) to CONSTRAINT_FAILED (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Constraint failed', { + code: 'P2004', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('CONSTRAINT_FAILED'); + }); + + it('maps P2005/P2006 (invalid field value) to INVALID_INPUT (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Invalid value', { + code: 'P2005', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('INVALID_INPUT'); + }); + + it('maps P2011/P2012/P2013 (missing required field) to MISSING_REQUIRED_FIELD (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Null constraint', { + code: 'P2011', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('MISSING_REQUIRED_FIELD'); + }); + + it('maps P2014/P2017 (relation violation) to RELATION_VIOLATION (409)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Relation violation', { + code: 'P2014', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(409); + expect(apiErr?.code).toBe('RELATION_VIOLATION'); + }); + + it('maps P2016 (query interpretation error) to INVALID_QUERY (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Query interpretation', { + code: 'P2016', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('INVALID_QUERY'); + }); + + it('maps P2021/P2022 (table or column does not exist) to INTERNAL_SERVER_ERROR (500)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Table does not exist', { + code: 'P2021', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(500); + expect(apiErr?.code).toBe('INTERNAL_SERVER_ERROR'); + expect(apiErr?.message).toBe('A technical error occurred. Please try again later.'); + }); + + it('maps other P-code request errors to DATABASE_ERROR (400)', () => { + const err = new Prisma.PrismaClientKnownRequestError('Other db error', { + code: 'P2099', + clientVersion: '1.0', + }); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('DATABASE_ERROR'); + expect(apiErr?.message).toBe('A database error occurred.'); + }); + + it('maps PrismaClientValidationError to VALIDATION_ERROR (400) without schema leakage', () => { + const err = new Prisma.PrismaClientValidationError( + 'Invalid `prisma.user.findMany()` invocation: Unknown argument `badProp`', + { clientVersion: '1.0' }, + ); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(400); + expect(apiErr?.code).toBe('VALIDATION_ERROR'); + expect(apiErr?.message).toBe('Invalid request data.'); + expect(apiErr?.message).not.toContain('findMany'); + expect(apiErr?.message).not.toContain('badProp'); + }); + + it('maps PrismaClientUnknownRequestError to INTERNAL_SERVER_ERROR (500)', () => { + const err = new Prisma.PrismaClientUnknownRequestError( + 'SELECT * FROM "User" WHERE id = $1 - syntax error', + { clientVersion: '1.0' }, + ); + const apiErr = fromPrismaError(err); + expect(apiErr?.status).toBe(500); + expect(apiErr?.code).toBe('INTERNAL_SERVER_ERROR'); + expect(apiErr?.message).toBe('A technical error occurred. Please try again later.'); + expect(apiErr?.message).not.toContain('SELECT'); + expect(apiErr?.message).not.toContain('User'); + }); + + it('returns null for non-Prisma errors', () => { + expect(fromPrismaError(new Error('generic'))).toBeNull(); + expect(fromPrismaError(null)).toBeNull(); + expect(fromPrismaError(123)).toBeNull(); + }); + + it('can be invoked via ApiError.fromPrismaError static method', () => { + const err = new Prisma.PrismaClientKnownRequestError('Conflict', { + code: 'P2002', + clientVersion: '1.0', + }); + const apiErr = ApiError.fromPrismaError(err); + expect(apiErr).toBeInstanceOf(ApiError); + expect(apiErr?.code).toBe('DUPLICATE_ENTRY'); + }); + }); + + describe('containsDatabaseDetails & sanitizeDatabaseErrorMessage', () => { + it('detects SQL query snippets', () => { + expect(containsDatabaseDetails('SELECT * FROM users')).toBe(true); + expect(containsDatabaseDetails('INSERT INTO users VALUES (1)')).toBe(true); + expect(containsDatabaseDetails('UPDATE accounts SET balance = 0')).toBe(true); + expect(containsDatabaseDetails('DELETE FROM accounts WHERE id = 1')).toBe(true); + expect(containsDatabaseDetails('CREATE TABLE test (id INT)')).toBe(true); + expect(containsDatabaseDetails('DROP TABLE test')).toBe(true); + }); + + it('detects table, column, and constraint disclosures', () => { + expect(containsDatabaseDetails('relation "streams" does not exist')).toBe(true); + expect(containsDatabaseDetails('table "users" not found')).toBe(true); + expect(containsDatabaseDetails('column "publicKey" is invalid')).toBe(true); + expect(containsDatabaseDetails('foreign key mismatch')).toBe(true); + expect(containsDatabaseDetails('syntax error at or near "SELECT"')).toBe(true); + expect(containsDatabaseDetails('prisma query failed')).toBe(true); + }); + + it('returns false for safe messages', () => { + expect(containsDatabaseDetails('User not found')).toBe(false); + expect(containsDatabaseDetails('Invalid amount provided')).toBe(false); + expect(containsDatabaseDetails('Unauthorized')).toBe(false); + }); + + it('sanitizes unsafe messages correctly according to status code', () => { + expect(sanitizeDatabaseErrorMessage('SELECT * FROM users', 500)).toBe( + 'A technical error occurred. Please try again later.', + ); + expect(sanitizeDatabaseErrorMessage('SELECT * FROM users', 400)).toBe( + 'A database error occurred.', + ); + expect(sanitizeDatabaseErrorMessage('User not found', 404)).toBe('User not found'); + }); + }); +}); diff --git a/backend/tests/error.middleware.test.ts b/backend/tests/error.middleware.test.ts index 4e3eaff7..f4a5fd2e 100644 --- a/backend/tests/error.middleware.test.ts +++ b/backend/tests/error.middleware.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { errorHandler } from '../src/middleware/error.middleware.js'; import { ZodError } from 'zod'; import { Prisma } from '../src/generated/prisma/index.js'; +import logger from '../src/logger.js'; +import { ApiError } from '../src/lib/api-error.js'; import type { Request, Response, NextFunction } from 'express'; describe('Error Middleware', () => { @@ -28,25 +30,188 @@ describe('Error Middleware', () => { })); }); - it('should handle Prisma P2002 error', () => { - const error = new Prisma.PrismaClientKnownRequestError('Conflict', { code: 'P2002', clientVersion: '1.0', meta: { target: ['email'] } }); + it('should handle Prisma P2002 error with sanitized DUPLICATE_ENTRY code and without schema leak', () => { + const loggerSpy = vi.spyOn(logger, 'error'); + const error = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`email`)', + { code: 'P2002', clientVersion: '1.0', meta: { target: ['email', 'User_email_key'] } }, + ); + errorHandler(error, req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - error: expect.objectContaining({ code: 'CONFLICT' }), - })); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'DUPLICATE_ENTRY', + message: 'A record with this unique value already exists.', + }, + }); + + // Verify response body never leaks column name or constraint + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('email'); + expect(jsonStr).not.toContain('User_email_key'); + + // Verify full error context is preserved in logger + expect(loggerSpy).toHaveBeenCalledWith('Unhandled error:', error); }); - it('should handle Prisma P2025 error', () => { - const error = new Prisma.PrismaClientKnownRequestError('Not found', { code: 'P2025', clientVersion: '1.0' }); + it('should handle Prisma P2025 error with sanitized RESOURCE_NOT_FOUND code', () => { + const error = new Prisma.PrismaClientKnownRequestError( + 'An operation failed because it depends on one or more records that were required but not found. Record of type User was not found.', + { code: 'P2025', clientVersion: '1.0', meta: { cause: 'Record to update not found.' } }, + ); + errorHandler(error, req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'RESOURCE_NOT_FOUND', + message: 'The requested record was not found.', + }, + }); + + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('User'); + expect(jsonStr).not.toContain('Record to update'); + }); + + it('should handle Prisma P2003 foreign key error without leaking field or relation names', () => { + const error = new Prisma.PrismaClientKnownRequestError( + 'Foreign key constraint failed on the field: `userId` table: `users`', + { code: 'P2003', clientVersion: '1.0', meta: { field_name: 'userId' } }, + ); + + errorHandler(error, req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'FOREIGN_KEY_VIOLATION', + message: 'Related resource constraint violation.', + }, + }); + + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('userId'); + expect(jsonStr).not.toContain('users'); + }); + + it('should handle Prisma P2000 value too long error', () => { + const error = new Prisma.PrismaClientKnownRequestError( + "The provided value for the column is too long for the column's type. Column: title", + { code: 'P2000', clientVersion: '1.0', meta: { column_name: 'title' } }, + ); + + errorHandler(error, req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'VALUE_OUT_OF_RANGE', + message: 'A provided value exceeds the allowable limit.', + }, + }); + + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + expect(JSON.stringify(jsonCall)).not.toContain('title'); + }); + + it('should handle PrismaClientValidationError without exposing model schema', () => { + const error = new Prisma.PrismaClientValidationError( + 'Invalid `prisma.user.create()` invocation: Unknown argument `unknownField`. Did you mean `publicKey`?', + { clientVersion: '1.0' }, + ); + + errorHandler(error, req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'VALIDATION_ERROR', + message: 'Invalid request data.', + }, + }); + + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('unknownField'); + expect(jsonStr).not.toContain('publicKey'); + expect(jsonStr).not.toContain('prisma.user'); + }); + + it('should handle PrismaClientUnknownRequestError without leaking raw SQL', () => { + const error = new Prisma.PrismaClientUnknownRequestError( + 'SELECT * FROM "users" WHERE id = $1 failed: syntax error at or near "SELECT"', + { clientVersion: '1.0' }, + ); + + errorHandler(error, req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'A technical error occurred. Please try again later.', + }, + }); + + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('SELECT'); + expect(jsonStr).not.toContain('users'); + }); + + it('should sanitize generic 4xx error messages containing raw SQL or table names', () => { + const error = new Error('Database error in table "users": syntax error at or near "SELECT"'); + (error as any).status = 400; + + errorHandler(error, req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(400); + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + const jsonStr = JSON.stringify(jsonCall); + expect(jsonStr).not.toContain('SELECT'); + expect(jsonStr).not.toContain('users'); + expect(jsonCall.error.message).toBe('A database error occurred.'); }); it('should handle generic error', () => { const error = new Error('Generic error'); errorHandler(error, req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'INTERNAL_SERVER_ERROR', + message: 'A technical error occurred. Please try again later.', + }, + }); + }); + + it('should handle ApiError instances and preserve code/details', () => { + const error = new ApiError(403, 'Forbidden action', 'FORBIDDEN_ACTION', { reason: 'denied' }); + errorHandler(error, req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: 'FORBIDDEN_ACTION', + message: 'Forbidden action', + details: { reason: 'denied' }, + }, + }); + }); + + it('should sanitize ApiError instances if message contains raw database details', () => { + const error = new ApiError(400, 'Error in table "users": syntax error at or near "SELECT"', 'BAD_INPUT'); + errorHandler(error, req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(400); + const jsonCall = (res.json as ReturnType).mock.calls[0]![0]; + expect(jsonCall.error.message).toBe('A database error occurred.'); + expect(jsonCall.error.code).toBe('BAD_INPUT'); }); it('should delegate to next when headers were already sent', () => { @@ -60,3 +225,4 @@ describe('Error Middleware', () => { expect(res.json).not.toHaveBeenCalled(); }); }); +