Null-password (SSO/LDAP-only) accounts: bcrypt throw → 500 on /api/auth/login, leaky 401 + brute-force-limiter evasion on /mcp #70
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Кратко
Аккаунты без локального пароля (
users.password IS NULL— SSO / LDAP-only пользователи) при попытке аутентификации по паролю приводят к исключению внутри bcrypt вместо чистого отказа в доступе.verifyUserCredentials()пропускает такого пользователя через guard и передаётnullвbcrypt.compare(), который реджектит промис сError: data and hash arguments required. Это неHttpException, поэтому:POST /api/auth/login— необработанная ошибка → HTTP 500 (а не 401);/mcp(HTTP Basic) — ошибка ловится и оборачивается вUnauthorizedExceptionс протекающим сообщением ("data and hash arguments required") и, главное, обходит brute-force-лимитер (см. ниже).Severity: low (это не обход аутентификации — войти под SSO-only аккаунтом по паролю всё равно нельзя). Но это дефект обработки ошибок + side-channel: оракул для перечисления SSO-only аккаунтов и уклонение от счётчика перебора.
Найдено в ходе security-review (находка #15).
Первопричина
apps/server/src/database/types/db.d.ts:376— колонка пароля nullable:apps/server/src/core/auth/services/auth.service.ts:87-115— guard покрывает только отсутствующего/деактивированного пользователя, но неnull-пароль:isUserDisabled()(common/helpers/utils.ts) проверяет толькоdeactivatedAt/deletedAt, пароль не смотрит — значит включённый SSO-only пользователь проходит guard и доходит доcomparePasswordHash(pw, null).comparePasswordHash(common/helpers/utils.ts:14) — тонкая обёртка над нативнымbcrypt(^6.0.0):Проверено фактическое поведение нативного bcrypt:
Глобального exception-фильтр�� в
apps/server/src/main.tsнет, поэтому на стандартном login-пути реджект становится дефолтным 500.Затронутые поверхности
POST /api/auth/login→auth.controller.ts:103→authService.login()→verifyUserCredentials(). Нет try/catch и нет глобального фильтра → HTTP 500 Internal Server Error для SSO/LDAP-only аккаунта (вместо 401). Это и есть «500 вместо 401»./mcpHTTP Basic →integrations/mcp/mcp-auth.helpers.ts:577-610. Реджект ловится в catch и ре-кидается какUnauthorizedException(err.message):"data and hash arguments required"уходит клиенту → info-leak / оракул: по нему (и по поведению лимитера) SSO-only аккаунт отличим от обычного «неверный пароль»;isCredentialsFailure(err)возвращает false (сообщение ≠CREDENTIALS_MISMATCH_MESSAGE), поэтомуdeps.limiter.recordFailure(...)НЕ вызывается → попытки против SSO-only аккаунта не учитываются brute-force-лимитером (обход счётчика).Воспроизведение
password = NULL(SSO/LDAP-only).POST /api/auth/loginc его email и любым паролем → 500 (ожидается 401 с обычным сообщением)./mcpсAuthorization: Basic <email:любой_пароль>→ 401 с телом"data and hash arguments required", при этом счётчик перебора по этому email/IP не растёт.Предлагаемый фикс
Трактовать
null/пустойuser.passwordтак же, как отсутствующего пользователя — выполнить dummy-сравнение (для паритета по времени) и бросить тот же унифицированный credentials-error:Эффект:
/mcpотдаёт унифицированное сообщениеCREDENTIALS_MISMATCH_MESSAGE→isCredentialsFailureснова true → brute-force-лимитер корректно инкрементируется, оракул закрыт;Дополнительно проверить родственный путь
changePassword()(auth.service.ts:167-173), гдеuser.passwordтоже передаётся вcomparePasswordHashпосле guardif (!user || isUserDisabled(user))— для SSO-only аккаунта там та же проблема.Желательно покрыть тестом в
verify-user-credentials.contract.spec.ts(вернуть унифицированный 401 дляpassword === null, без throw из bcrypt).