mirror of
https://github.com/documenso/documenso.git
synced 2026-07-13 06:25:01 +10:00
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:
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
|
||||
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
|
||||
Generated
+10
@@ -22308,6 +22308,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mailchecker": {
|
||||
"version": "6.0.20",
|
||||
"resolved": "https://registry.npmjs.org/mailchecker/-/mailchecker-6.0.20.tgz",
|
||||
"integrity": "sha512-mZ3kmtfXzGj06prtNm6d8an7D++Kf1G4jEkPZ1QQyhknYNLkmGoMtfaNPNHJU6E8J+Bm3AcZlIIfq5D6L4MS2g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
|
||||
@@ -30939,6 +30948,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user