feat: block disposable email signups (#2883)

Reject disposable / throwaway email providers (mailinator, yopmail,
10minutemail, ...) across all signup paths: email/password, Google,
Microsoft, personal OIDC and organisation OIDC. Backed by the
mailchecker package (offline, ~55k domains, subdomain-aware).

Exposes a SIGNUP_DISPOSABLE_EMAIL error code so the signup form and
SSO redirect alert can show a dedicated message instead of the
generic 'signup disabled' one.
This commit is contained in:
Lucas Smith
2026-05-28 21:15:27 +09:00
committed by GitHub
parent d304d8720c
commit 7e8da85bd8
8 changed files with 91 additions and 3 deletions
@@ -18,6 +18,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
@@ -1,5 +1,9 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
@@ -132,6 +136,15 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
if (isDisposableEmail(email)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
@@ -1,5 +1,5 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
@@ -74,6 +74,15 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
if (isDisposableEmail(email)) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
userToLink = await prisma.user.create({
data: {
email: email,
+11 -1
View File
@@ -1,4 +1,8 @@
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -214,6 +218,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
if (isDisposableEmail(email)) {
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
+43
View File
@@ -1,3 +1,4 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
@@ -121,6 +122,48 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if the given email belongs to a known disposable / throwaway provider
* (e.g. mailinator, yopmail, 10minutemail, ...).
*
* Backed by the `mailchecker` package which bundles a static list of 55k+
* disposable domains. The check is offline and synchronous.
*
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
* `mailinator.com`).
*
* Returns `true` when the email is disposable and should be rejected.
* Email format validation is intentionally NOT performed here — that is
* handled by Zod upstream.
*/
export const isDisposableEmail = (email: string): boolean => {
const domain = email.toLowerCase().split('@').pop();
if (!domain) {
return false;
}
const blacklist = MailChecker.blacklist();
let currentDomain: string | undefined = domain;
while (currentDomain) {
if (blacklist.has(currentDomain)) {
return true;
}
const nextDot = currentDomain.indexOf('.');
if (nextDot === -1) {
break;
}
currentDomain = currentDomain.slice(nextDot + 1);
}
return false;
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
+1
View File
@@ -51,6 +51,7 @@
"konva": "^10.0.9",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"mailchecker": "^6.0.20",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",