Skip to content
Merged
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
17 changes: 17 additions & 0 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,28 @@ const initModulesServerRoutes = async (app) => {

/**
* Configure error handling
* @param {import('express').Express} app - Express application instance
* @returns {void}
*/
const initErrorRoutes = (app) => {
/**
* Express 4-arity error handler.
* In production: returns a generic 500 to prevent leaking internal error details (#3726).
* In non-production: includes err.message and err.code for debugging.
* @param {Error} err - The error object caught by Express
* @param {import('express').Request} req - Express request object
* @param {import('express').Response} res - Express response object
* @param {import('express').NextFunction} next - Express next function
* @returns {void}
*/
app.use((err, req, res, next) => {
if (!err) return next();
logger.error('Unhandled express error', { error: err });
// In production never leak internal error details (message, code) to clients.
// Detailed errors are still logged above for observability.
if (process.env.NODE_ENV === 'production') {
return res.status(err.status || 500).send({ message: 'Internal Server Error' });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.status(err.status || 500).send({
message: err.message,
code: err.code,
Expand Down
167 changes: 167 additions & 0 deletions lib/services/tests/express.errorroutes.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* Module dependencies.
*
* Unit tests for express.js initErrorRoutes.
* Security regression guard: err.message/code must NOT be sent to clients in production (#3726).
*/
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';

describe('express initErrorRoutes — prod error leak guard (#3726):', () => {
let originalNodeEnv;

beforeEach(() => {
originalNodeEnv = process.env.NODE_ENV;
jest.resetModules();
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});

/**
* Helper: extract initErrorRoutes from express.js (with all heavy deps mocked).
* @returns {Promise<Function>} The initErrorRoutes function extracted from the module
*/
const getInitErrorRoutes = async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
jest.unstable_mockModule('../../../config/index.js', () => ({
default: {
domain: 'http://localhost:3000',
app: { title: 'Test', description: '', keywords: '', url: '', logo: '' },
secure: { ssl: false },
log: {},
bodyParser: {},
csrf: {},
cors: { origin: [], credentials: false, optionsSuccessStatus: 200 },
trust: { proxy: false },
swagger: { enable: false },
files: { routes: [], configs: [], policies: [], preRoutes: [], swagger: [], guides: [] },
analytics: { posthog: { autoCapture: false } },
docs: {},
},
}));
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
getLogFormat: jest.fn().mockReturnValue('combined'),
getMorganOptions: jest.fn().mockReturnValue({}),
},
}));
jest.unstable_mockModule('../../../lib/helpers/guides.js', () => ({
default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() },
}));
jest.unstable_mockModule('../../../lib/middlewares/requestId.js', () => ({
default: jest.fn((req, res, next) => next()),
}));
jest.unstable_mockModule('../../../lib/middlewares/posthog-context.middleware.js', () => ({
posthogContextMiddleware: jest.fn((req, res, next) => next()),
}));
jest.unstable_mockModule('../../../lib/services/errorTracker.js', () => ({
default: { setupExpressErrorHandler: jest.fn() },
}));
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
default: { init: jest.fn().mockResolvedValue(undefined), identify: jest.fn(), groupIdentify: jest.fn() },
}));
jest.unstable_mockModule('../../../lib/middlewares/analytics.js', () => ({
default: jest.fn((req, res, next) => next()),
}));
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
default: { discoverPolicies: jest.fn().mockResolvedValue(undefined), defineAbilityFor: jest.fn().mockResolvedValue({}) },
}));

const mod = await import('../../../lib/services/express.js');
return mod.default.initErrorRoutes;
};

test('in production: responds with generic { message: "Internal Server Error" } — no err.message/code leak', async () => {
process.env.NODE_ENV = 'production';
const initErrorRoutes = await getInitErrorRoutes();

// Capture the error handler registered via app.use
let errorHandler;
const app = {
use: jest.fn((handler) => { errorHandler = handler; }),
};
initErrorRoutes(app);
expect(typeof errorHandler).toBe('function');

const internalErr = new Error('E11000 duplicate key index: email_1');
internalErr.code = 11000;
internalErr.status = 500;

const sendPayloads = [];
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn((body) => { sendPayloads.push(body); return res; }),
};

await errorHandler(internalErr, {}, res, jest.fn());

expect(res.status).toHaveBeenCalledWith(500);
expect(sendPayloads).toHaveLength(1);
const body = sendPayloads[0];
// Must NOT include internal error detail
expect(body.message).toBe('Internal Server Error');
expect(body.code).toBeUndefined();
// Must NOT contain the raw error message
expect(JSON.stringify(body)).not.toContain('E11000');
expect(JSON.stringify(body)).not.toContain('email_1');
});

test('in non-production (development): responds with detailed err.message + err.code', async () => {
process.env.NODE_ENV = 'development';
const initErrorRoutes = await getInitErrorRoutes();

let errorHandler;
const app = {
use: jest.fn((handler) => { errorHandler = handler; }),
};
initErrorRoutes(app);

const devErr = new Error('Something went wrong internally');
devErr.code = 'ERR_INTERNAL';
devErr.status = 500;

const sendPayloads = [];
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn((body) => { sendPayloads.push(body); return res; }),
};

await errorHandler(devErr, {}, res, jest.fn());

const body = sendPayloads[0];
expect(body.message).toBe('Something went wrong internally');
expect(body.code).toBe('ERR_INTERNAL');
});

test('in test env: responds with detailed err.message + err.code (non-production path)', async () => {
process.env.NODE_ENV = 'test';
const initErrorRoutes = await getInitErrorRoutes();

let errorHandler;
const app = {
use: jest.fn((handler) => { errorHandler = handler; }),
};
initErrorRoutes(app);

const testErr = new Error('test internal error');
testErr.code = 'ERR_TEST';
testErr.status = 422;

const sendPayloads = [];
const res = {
status: jest.fn().mockReturnThis(),
send: jest.fn((body) => { sendPayloads.push(body); return res; }),
};

await errorHandler(testErr, {}, res, jest.fn());

const body = sendPayloads[0];
expect(res.status).toHaveBeenCalledWith(422);
expect(body.message).toBe('test internal error');
expect(body.code).toBe('ERR_TEST');
});
});
42 changes: 32 additions & 10 deletions modules/auth/controllers/auth.password.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,36 @@ const tokenCookieOptions = {
* @returns {Promise<void>} Sends a JSON response with reset status.
*/
const forgot = async (req, res) => {
let user;
/**
* @returns {void} Sends a uniform 200 response regardless of account existence or mail outcome.
*/
const uniformSuccess = () => responses.success(res, 'If that email exists, a reset link has been sent.')({ status: true });

// check input
if (!req.body.email) return responses.error(res, 422, 'Unprocessable Entity', 'Mail field must not be blank')();
const email = String(req.body.email);
// get user generate and add token

let user;
try {
user = await UserService.getBrut({ email });
if (!user) return responses.error(res, 400, 'Bad Request', 'No account with that email has been found')();
if (user.provider !== 'local')
return responses.error(res, 400, 'Bad Request', `It seems like you signed up using your ${user.provider} account`)();
} catch (err) {
return responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}

// Unknown email or OAuth user — perform a dummy bcrypt compare to equalise
// response timing (anti-timing-oracle). The sentinel is a valid precomputed
// bcrypt hash (cost=10, 60 chars) so bcrypt runs the full KDF instead of
// short-circuiting on format validation.
if (!user || user.provider !== 'local') {
await AuthService.comparePassword(
'dummy-timing-equaliser',
'$2b$10$wotSb2xPb8VF8lpBkulk0.e2xvYvDedhjMyuG/7w3GdPl1vdvogY2',
);
return uniformSuccess();
}

// Known local user — generate token, persist, and send the reset email.
try {
const edit = {
resetPasswordToken: jwt.sign({ exp: Date.now() + 3600000 }, config.jwt.secret, { algorithm: 'HS256' }),
resetPasswordExpires: Date.now() + 3600000,
Expand All @@ -43,7 +63,7 @@ const forgot = async (req, res) => {
} catch (err) {
return responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
// send mail

try {
const mail = await mails.sendMail({
template: 'reset-password-email',
Expand All @@ -56,11 +76,13 @@ const forgot = async (req, res) => {
appContact: config.app.contact,
},
});
if (!mail || !mail.accepted) return responses.error(res, 400, 'Bad Request', 'Failure sending email')();
return responses.success(res, 'An email has been sent with further instructions')({ status: true });
} catch (_mailErr) {
return responses.error(res, 400, 'Bad Request', 'Failure sending email')();
if (!mail || !mail.accepted) {
logger.error('auth.password.forgot: mail not accepted', { email: user.email });
}
} catch (mailErr) {
logger.error('auth.password.forgot: sendMail threw', { message: mailErr?.message, stack: mailErr?.stack });
}
return uniformSuccess();
};

/**
Expand Down
50 changes: 28 additions & 22 deletions modules/auth/tests/auth.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,17 +292,20 @@ describe('Auth integration tests:', () => {
}
});

test('forgot password request for non-existent email should return 400', async () => {
test('forgot password request for non-existent email should return uniform 200 (no enumeration)', async () => {
try {
const result = await agent
.post('/api/auth/forgot')
.send({
email: 'falseemail@gmail.com',
})
.expect(400);
.expect(200);

expect(result.body.message).toBe('Bad Request');
expect(result.body.description).toBe('No account with that email has been found');
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('If that email exists, a reset link has been sent.');
// Must NOT contain any hint of account existence
expect(JSON.stringify(result.body)).not.toContain('No account');
expect(JSON.stringify(result.body)).not.toContain('found');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -342,7 +345,7 @@ describe('Auth integration tests:', () => {
}
});

test('forgot password request for non-local provider should return 400', async () => {
test('forgot password request for non-local provider should return uniform 200 (no provider enumeration)', async () => {
_userEdited.provider = 'facebook';

try {
Expand All @@ -359,9 +362,12 @@ describe('Auth integration tests:', () => {
.send({
email: userEdited.email,
})
.expect(400);
expect(result.body.message).toBe('Bad Request');
expect(result.body.description).toBe(`It seems like you signed up using your ${userEdited.provider} account`);
.expect(200);
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('If that email exists, a reset link has been sent.');
// Must NOT reveal the OAuth provider name
expect(JSON.stringify(result.body)).not.toContain('facebook');
expect(JSON.stringify(result.body)).not.toContain('signed up using');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand All @@ -375,16 +381,16 @@ describe('Auth integration tests:', () => {
}
});

test('should initiate password reset process for valid email', async () => {
test('should initiate password reset process for valid email and return uniform 200', async () => {
try {
const result = await agent
.post('/api/auth/forgot')
.send({
email: user.email,
})
.expect(400);
expect(result.body.message).toBe('Bad Request');
expect(result.body.description).toBe('Failure sending email');
.expect(200);
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('If that email exists, a reset link has been sent.');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand All @@ -410,10 +416,10 @@ describe('Auth integration tests:', () => {
.send({
email: user.email,
})
.expect(400);
.expect(200);

expect(result.body.message).toBe('Bad Request');
expect(result.body.description).toBe('Failure sending email');
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('If that email exists, a reset link has been sent.');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -447,10 +453,10 @@ describe('Auth integration tests:', () => {
.send({
email: user.email,
})
.expect(400);
.expect(200);

expect(result.body.message).toBe('Bad Request');
expect(result.body.description).toBe('Failure sending email');
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('If that email exists, a reset link has been sent.');
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -1246,9 +1252,9 @@ describe('Auth integration tests:', () => {
});

test('should reject password reset with a weak password (zxcvbn strength gate)', async () => {
// Trigger forgot to generate a reset token (email send fails in test env, which is expected)
// Trigger forgot to generate a reset token — returns uniform 200 regardless of mail outcome
try {
await agent.post('/api/auth/forgot').send({ email: credentials[0].email }).expect(400);
await agent.post('/api/auth/forgot').send({ email: credentials[0].email }).expect(200);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -1277,9 +1283,9 @@ describe('Auth integration tests:', () => {
});

test('should successfully reset password with a valid token', async () => {
// Trigger forgot to generate a reset token (email send fails in test env, which is expected)
// Trigger forgot to generate a reset token — returns uniform 200 regardless of mail outcome
try {
await agent.post('/api/auth/forgot').send({ email: credentials[0].email }).expect(400);
await agent.post('/api/auth/forgot').send({ email: credentials[0].email }).expect(200);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down
Loading
Loading