From 8011adadb6bfdb064c12648d1f9dfd465c6602ff Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 29 Apr 2026 22:53:16 +0200 Subject: [PATCH] fix(server): prevent login timing oracle by always running bcrypt Always call compareBcrypt in the login path regardless of whether the email is registered. When no user is found, a dummy hash is used so the bcrypt KDF still runs and response latency is constant, making it impossible to enumerate valid email addresses by measuring response time. --- server/src/constants.ts | 4 ++++ server/src/services/auth.service.ts | 15 ++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/server/src/constants.ts b/server/src/constants.ts index 20249bf1ca..89c2fd733b 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -42,6 +42,10 @@ export const VECTOR_INDEX_TABLES = { export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2; export const SALT_ROUNDS = 10; +// Syntactically valid bcrypt hash (cost 10) used as a dummy in login() so that +// bcrypt.compareSync always runs even when the email is not registered, preventing +// timing-based user enumeration. +export const LOGIN_DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZabcde'; export const IWorker = 'IWorker'; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 031d893b9f..82c2b3fb48 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; -import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { AuthDto, @@ -62,15 +62,12 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Password login has been disabled'); } - let user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); - if (user) { - const isAuthenticated = this.validateSecret(dto.password, user.password); - if (!isAuthenticated) { - user = undefined; - } - } + const user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); + // Always run bcrypt so response time is constant regardless of whether the email + // is registered, preventing timing-based user enumeration. + const authenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH); - if (!user) { + if (!user || !user.password || !authenticated) { this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); throw new UnauthorizedException('Incorrect email or password'); }