Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextFunction, Request, Response } from 'express';
import { logger } from '../utils/logger';
import { sanitizeErrorDetails } from './errorResponse';

/**
* Error types for Arbellar Backend
Expand Down Expand Up @@ -226,9 +227,12 @@ export const errorHandler = (
},
};

// Add details if present
// Add sanitized details if present
if (details) {
errorResponse.error.details = details;
const sanitized = sanitizeErrorDetails(details);
if (sanitized !== undefined) {
errorResponse.error.details = sanitized;
}
}

// Include stack trace in development for debugging
Expand Down
76 changes: 76 additions & 0 deletions src/middleware/errorResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Error response sanitizer
*
* Strips sensitive fields from JSON error details before they leave the
* application, preventing accidental leakage of internal state, credentials,
* or connection metadata through error responses.
*/

/**
* Keys that must never be exposed in an error response.
*
* Compared case-insensitively. Extend as new sensitive fields are introduced.
*/
const DENY_LIST = new Set<string>([
'password',
'passwordhash',
'secret',
'token',
'apikey',
'privatekey',
'private_key',
'keypair',
'seed',
'mnemonic',
'jwt',
'authorization',
'mongodburi',
'connectionstring',
'uri',
'cursor',
'stacktrace',
'stack',
'internal',
'env',
'process',
]);

/**
* Sanitize error details by recursively removing sensitive keys.
*
* This helper is pure: it performs no I/O, imports, or side effects, and is
* safe to call from any error handler.
*
* @param details - Arbitrary error details to sanitize.
* @returns A sanitized copy with all deny-listed keys removed, or `undefined`
* when the input is not a plain object or is `null`.
*
* @example
* ```ts
* sanitizeErrorDetails({ user: { password: 'x' }, safe: 1 })
* // { user: {}, safe: 1 }
* ```
*/
export function sanitizeErrorDetails(details: unknown): unknown {
if (details == null || typeof details !== 'object') {
return details;
}

if (Array.isArray(details)) {
return details.map(sanitizeErrorDetails);
}

const source = details as Record<string, unknown>;
const result: Record<string, unknown> = {};

for (const [key, value] of Object.entries(source)) {
if (DENY_LIST.has(key.toLowerCase())) {
continue;
}
result[key] = sanitizeErrorDetails(value);
}

return result;
}

export default { sanitizeErrorDetails };
1 change: 1 addition & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

export * from './errorHandler';
export * from './errorResponse';

// Additional middleware will be exported here as they are implemented
// export * from './authentication';
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/middleware/errorResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Unit tests for the error response sanitizer.
*
* Covers top-level deny-listed keys, nested deny-listed keys, arrays with
* deny-listed keys, non-object input, and deeply nested recursive sanitization.
*/

import { describe, expect, it } from 'vitest';
import { sanitizeErrorDetails } from '../../../src/middleware/errorResponse';

describe('sanitizeErrorDetails', () => {
it('removes deny-listed keys at the top level', () => {
const input = {
user: 'alice',
password: 'hunter2',
secret: 's3cret',
token: 'abc123',
apiKey: 'key',
};
const result = sanitizeErrorDetails(input) as Record<string, unknown>;

expect(result.user).toBe('alice');
expect(result.password).toBeUndefined();
expect(result.secret).toBeUndefined();
expect(result.token).toBeUndefined();
expect(result.apiKey).toBeUndefined();
});

it('removes deny-listed keys at nested levels', () => {
const input = {
user: {
name: 'bob',
passwordHash: 'hash123',
email: 'bob@example.com',
},
connection: {
mongodbUri: 'mongodb://local',
port: 27017,
},
};
const result = sanitizeErrorDetails(input) as Record<string, any>;

expect(result.user.name).toBe('bob');
expect(result.user.passwordHash).toBeUndefined();
expect(result.user.email).toBe('bob@example.com');
expect(result.connection.mongodbUri).toBeUndefined();
expect(result.connection.port).toBe(27017);
});

it('removes deny-listed keys inside arrays', () => {
const input = {
items: [
{ id: 1, token: 't1', name: 'a' },
{ id: 2, privateKey: 'pk', name: 'b' },
{ id: 3, name: 'c' },
],
};
const result = sanitizeErrorDetails(input) as Record<string, any>;

expect(result.items[0].id).toBe(1);
expect(result.items[0].token).toBeUndefined();
expect(result.items[0].name).toBe('a');
expect(result.items[1].privateKey).toBeUndefined();
expect(result.items[1].name).toBe('b');
expect(result.items[2].id).toBe(3);
});

it('returns non-object input unchanged', () => {
expect(sanitizeErrorDetails(null)).toBe(null);
expect(sanitizeErrorDetails(undefined)).toBe(undefined);
expect(sanitizeErrorDetails(42)).toBe(42);
expect(sanitizeErrorDetails('hello')).toBe('hello');
expect(sanitizeErrorDetails(true)).toBe(true);
});

it('handles empty objects', () => {
expect(sanitizeErrorDetails({})).toEqual({});
});

it('removes deeply nested deny-listed keys recursively', () => {
const input = {
level1: {
level2: {
level3: {
jwt: 'jwt_token',
safe: 'value',
},
},
},
};
const result = sanitizeErrorDetails(input) as Record<string, any>;

expect(result.level1.level2.level3.jwt).toBeUndefined();
expect(result.level1.level2.level3.safe).toBe('value');
});

it('is case-insensitive for deny-listed keys', () => {
const input = {
PASSWORD: 'x',
Token: 'y',
ApiKey: 'z',
safe: 'ok',
};
const result = sanitizeErrorDetails(input) as Record<string, unknown>;

expect(result.PASSWORD).toBeUndefined();
expect(result.Token).toBeUndefined();
expect(result.ApiKey).toBeUndefined();
expect(result.safe).toBe('ok');
});

it('handles arrays at the top level', () => {
const input = [
{ password: 'a', safe: 1 },
{ secret: 'b', safe: 2 },
{ safe: 3 },
];
const result = sanitizeErrorDetails(input) as Array<Record<string, unknown>>;

expect(result[0].password).toBeUndefined();
expect(result[0].safe).toBe(1);
expect(result[1].secret).toBeUndefined();
expect(result[1].safe).toBe(2);
expect(result[2].safe).toBe(3);
});

it('handles nested arrays within objects', () => {
const input = {
logs: [
{ message: 'ok', stack: 'trace' },
{ message: 'warn' },
],
};
const result = sanitizeErrorDetails(input) as Record<string, any>;

expect(result.logs[0].message).toBe('ok');
expect(result.logs[0].stack).toBeUndefined();
expect(result.logs[1].message).toBe('warn');
});

it('does not mutate the original input', () => {
const original = { user: 'a', password: 'b', nested: { token: 'c', safe: 'd' } };
const copy = JSON.parse(JSON.stringify(original));

sanitizeErrorDetails(original);

expect(original).toEqual(copy);
});
});