From f2394d6eba64e2b06498b385fb1cfd815d26b9cf Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:09:58 +0700 Subject: [PATCH 1/2] fix(auth): mask break-glass allowlist membership behind INVALID_CREDENTIALS The /email-password/authorize gate for disabled password signin returned SIGNIN_DISABLED for non-allowlisted emails but INVALID_CREDENTIALS for allowlisted ones, letting an unauthenticated prober enumerate which emails are on the NEXT_PRIVATE_BREAK_GLASS_EMAILS allowlist and then targeted-brute-force them. The gate also ran before the rate limiter, so enumeration consumed no rate limit budget. Move the gate after the rate limit and CSRF/captcha checks and reject with the same INVALID_CREDENTIALS error (and message) as a wrong password, so the endpoint cannot distinguish allowlisted emails. The break-glass path itself is unchanged: allowlisted admins still pass the gate and authenticate normally. The other SigninDisabled gates (update-password, forgot-password, reset-password) branch on config only, not on the email, so they leak nothing and stay as they are. --- packages/auth/server/routes/email-password.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 6cdda13847..1c2bd5d297 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -68,15 +68,6 @@ export const emailPasswordRoute = new Hono() const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json'); - // Break-glass: when password signin is disabled suite-wide, allowlisted - // admin emails (see NEXT_PRIVATE_BREAK_GLASS_EMAILS) may still sign in - // via /signin?direct=1 while the OIDC provider is unreachable. - if (!isSigninEnabledForProvider('email') && !isBreakGlassEmail(email)) { - throw new AppError(AuthenticationErrorCode.SigninDisabled, { - statusCode: 400, - }); - } - const loginLimitResult = await loginRateLimit.check({ ip: requestMetadata.ipAddress ?? 'unknown', identifier: email, @@ -104,6 +95,18 @@ export const emailPasswordRoute = new Hono() ipAddress: requestMetadata.ipAddress, }); + // Break-glass: when password signin is disabled suite-wide, allowlisted + // admin emails (see NEXT_PRIVATE_BREAK_GLASS_EMAILS) may still sign in + // via /signin?direct=1 while the OIDC provider is unreachable. The gate + // sits after the rate limit and CSRF/captcha checks and rejects with the + // same INVALID_CREDENTIALS error as a wrong password, so probing the + // endpoint cannot reveal which emails are on the allowlist. + if (!isSigninEnabledForProvider('email') && !isBreakGlassEmail(email)) { + throw new AppError(AuthenticationErrorCode.InvalidCredentials, { + message: 'Invalid email or password', + }); + } + if (email.toLowerCase() === legacyServiceAccountEmail() || email.toLowerCase() === deletedServiceAccountEmail()) { return c.text('FORBIDDEN', 403); } From 31927f4a86685c96269f4cdd3e8b3fe1241def9a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:18:06 +0700 Subject: [PATCH 2/2] docs: align sign-in restriction docs with the INVALID_CREDENTIALS gate The master-switch paragraph still claimed the signin endpoint rejects with SIGNIN_DISABLED; that now only holds for the password management endpoints. Also document NEXT_PRIVATE_BREAK_GLASS_EMAILS, which was only described in .env.example. --- .../content/docs/self-hosting/configuration/environment.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index e026a4e134..a7da514215 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -313,8 +313,9 @@ NEXT_PUBLIC_DISABLE_SIGNUP="true" You can control which methods are available for users to sign in with the following environment variables: -- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error. +- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin`. Server-side, the password management endpoints (`update-password`, `forgot-password`, `reset-password`) reject requests with a `SIGNIN_DISABLED` error, while the signin endpoint itself answers with the same `INVALID_CREDENTIALS` error as a wrong password so that probing cannot reveal which accounts exist. - **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected. +- **`NEXT_PRIVATE_BREAK_GLASS_EMAILS`**: Comma-separated admin emails that keep password signin available via `/signin?direct=1` even when email/password signin is disabled suite-wide (e.g. redirect-only OIDC deployments). Intended as an escape hatch for when the identity provider is unreachable. Attempts from emails outside the list are rejected with the same error as a wrong password, so the allowlist cannot be probed. - **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point. These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.