From 4f703c8a2205d63b4573e3b8b5720b0313574f23 Mon Sep 17 00:00:00 2001 From: Alimedhat000 Date: Wed, 26 Aug 2026 02:43:12 +0300 Subject: [PATCH] fix(auth): prevent account enumeration on register and login (#83) Registration now returns a single generic conflict message regardless of which field collided, and login returns an identical 401 body with a non-empty generic message for both unknown-user and bad-password cases (previously the body serialized to {}). A dummy bcrypt compare equalizes response timing when the user does not exist. --- server/src/controllers/auth.controller.ts | 14 ++++-- server/test/auth.test.ts | 55 +++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 86abcff..3b4c098 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -10,6 +10,12 @@ import { getClientInfo } from '@/utils/getClientInfo'; import { LoginUserSchema } from '@/validations/login.schema'; import { RegisterUserSchema } from '@/validations/register.schema'; +/** + * Fixed bcrypt hash used to equalize timing when login is attempted for an + * unknown email (#83): one bcrypt compare runs in both failure paths. + */ +const DUMMY_PASSWORD_HASH = '$2b$10$lZKU2EGQLmnz9Fi65/t3GO/coz9zBl6zMMvDyd0EOBgeU1Y28ESHG'; + export const registerUser = asyncErrorWrapper(async (req: Request, res: Response) => { const clientInfo = getClientInfo(req); @@ -48,7 +54,7 @@ export const registerUser = asyncErrorWrapper(async (req: Request, res: Response username, existingField: existing.email === email ? 'email' : 'username', }); - res.status(StatusCodes.CONFLICT).json({ error: 'Username or email already exists' }); + res.status(StatusCodes.CONFLICT).json({ error: 'Registration failed' }); return; } @@ -112,13 +118,15 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = const user = await prisma.user.findUnique({ where: { email } }); if (!user) { + await bcrypt.compare(password, DUMMY_PASSWORD_HASH); + logger.warn('Login failed - user not found', { action: 'LOGIN_USER_NOT_FOUND', ...clientInfo, email, }); - res.status(StatusCodes.UNAUTHORIZED).json({ error: result.error }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' }); return; } @@ -133,7 +141,7 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) = username: user.username, }); - res.status(StatusCodes.UNAUTHORIZED).json({ error: result.error }); + res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' }); return; } diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index 2b1847d..c85c426 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -80,6 +80,35 @@ describe('Auth Routes', () => { }); }); + it('should not leak which field collided when registration fails (#83)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'enum-email@test.dev', + username: 'enumuser', + password: 'secure123', + }); + + const dupEmail = await request(app).post('/api/auth/register').send({ + email: 'enum-email@test.dev', + username: 'unusedname', + password: 'secure123', + }); + + const dupUsername = await request(app).post('/api/auth/register').send({ + email: 'unused@test.dev', + username: 'enumuser', + password: 'secure123', + }); + + expect(dupEmail.status).toBe(StatusCodes.CONFLICT); + expect(dupUsername.status).toBe(StatusCodes.CONFLICT); + // Uniform response regardless of which field collided + expect(dupEmail.body).toEqual(dupUsername.body); + expect(typeof dupEmail.body.error).toBe('string'); + expect(dupEmail.body.error).not.toMatch(/email/i); + expect(dupEmail.body.error).not.toMatch(/username/i); + expect(dupEmail.body.error).not.toMatch(/exists/i); + }); + it('should reject login with invalid password', async () => { await request(app).post('/api/auth/register').send({ email: 'test@test.dev', @@ -104,6 +133,32 @@ describe('Auth Routes', () => { expect(res.status).toBe(StatusCodes.UNAUTHORIZED); }); + it('should not distinguish unknown user from invalid password on login (#83)', async () => { + await request(app).post('/api/auth/register').send({ + email: 'loginenum@test.dev', + username: 'loginenum', + password: 'secure123', + }); + + const badPassword = await request(app).post('/api/auth/login').send({ + email: 'loginenum@test.dev', + password: 'wrongpass', + }); + + const unknownUser = await request(app).post('/api/auth/login').send({ + email: 'ghost@test.dev', + password: 'anypassword', + }); + + expect(badPassword.status).toBe(StatusCodes.UNAUTHORIZED); + expect(unknownUser.status).toBe(StatusCodes.UNAUTHORIZED); + // Identical responses so probing cannot tell whether the account exists + expect(unknownUser.body).toEqual(badPassword.body); + // And the response carries an actual message (not the legacy empty `{}`) + expect(typeof unknownUser.body.error).toBe('string'); + expect(unknownUser.body.error.length).toBeGreaterThan(0); + }); + it('should reject registration with invalid email format', async () => { const res = await request(app).post('/api/auth/register').send({ email: 'invalid-email',