diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 354d6175d..a53131f08 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z export const SIGNUP_ERROR_MESSAGES: Record = { 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.`, }; diff --git a/package-lock.json b/package-lock.json index 935b1011e..7f51b8942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/auth/server/lib/errors/error-codes.ts b/packages/auth/server/lib/errors/error-codes.ts index 93f6a8f10..858457190 100644 --- a/packages/auth/server/lib/errors/error-codes.ts +++ b/packages/auth/server/lib/errors/error-codes.ts @@ -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', diff --git a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts index 7f4ab0b70..ed70d9713 100644 --- a/packages/auth/server/lib/utils/handle-oauth-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-callback-url.ts @@ -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({ diff --git a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts index fbf99953f..64a52d118 100644 --- a/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-organisation-callback-url.ts @@ -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, diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 7ea0584ef..26beb0175 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -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() }); } + 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; diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 2561c8e90..095bc5101 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -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. diff --git a/packages/lib/package.json b/packages/lib/package.json index 8f33ba24d..288e07241 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -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",