From d304d8720c22d9335ca5a95b91505a219ae5d295 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 28 May 2026 17:09:09 +1000 Subject: [PATCH 01/91] fix: add temp email rate limit (#2879) --- .../emails/send-signing-email.handler.ts | 34 +++++++++++---- .../server-only/document/resend-document.ts | 28 +++++++++---- .../email/assert-org-email-send-allowed.ts | 42 +++++++++++++++++++ .../server-only/email/get-email-context.ts | 3 ++ .../lib/server-only/rate-limit/rate-limits.ts | 14 +++++++ 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 packages/lib/server-only/email/assert-org-email-send-allowed.ts diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index 4ddb213b8..e60531adf 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -16,6 +16,7 @@ import { createElement } from 'react'; import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles'; +import { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed'; import { getEmailContext } from '../../../server-only/email/get-email-context'; import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; @@ -83,14 +84,15 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini return; } - const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); const customEmail = envelope?.documentMeta; const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK; @@ -162,6 +164,22 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini }); if (isRecipientEmailValidForSending(recipient)) { + const sendCheck = await assertOrgEmailSendAllowed({ organisationId }); + + if (!sendCheck.allowed) { + // TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried. + io.logger.warn({ + msg: 'Recipient signing email dropped: org rate limit exceeded', + organisationId, + recipientId: recipient.id, + envelopeId: envelope.id, + reason: sendCheck.reason, + resetsAt: sendCheck.resetsAt, + }); + + return; + } + await io.runTask('send-signing-email', async () => { const [html, text] = await Promise.all([ renderEmailWithI18N(template, { lang: emailLanguage, branding }), diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index b32fb6dc1..9b4ab968e 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -2,6 +2,7 @@ import { mailer } from '@documenso/email/mailer'; import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration'; import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; @@ -26,6 +27,7 @@ import { isDocumentCompleted } from '../../utils/document'; import type { EnvelopeIdOptions } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; +import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed'; import { getEmailContext } from '../email/get-email-context'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -120,14 +122,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe return envelope; } - const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); await Promise.all( recipientsToRemind.map(async (recipient) => { @@ -200,6 +203,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe }), ]); + const sendCheck = await assertOrgEmailSendAllowed({ organisationId }); + + if (!sendCheck.allowed) { + throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, { + message: 'Organisation email send rate limit exceeded', + userMessage: 'Email send rate limit reached. Please try again in a few minutes.', + }); + } + // Send email outside any transaction to avoid holding a connection // open during network I/O. await mailer.sendMail({ diff --git a/packages/lib/server-only/email/assert-org-email-send-allowed.ts b/packages/lib/server-only/email/assert-org-email-send-allowed.ts new file mode 100644 index 000000000..0260ea680 --- /dev/null +++ b/packages/lib/server-only/email/assert-org-email-send-allowed.ts @@ -0,0 +1,42 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { + recipientEmailRateLimit1d, + recipientEmailRateLimit5m, +} from '@documenso/lib/server-only/rate-limit/rate-limits'; + +type AssertOrgEmailSendAllowedOptions = { + organisationId: string; +}; + +type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date }; + +/** + * TEMPORARY: rate-limit unsolicited recipient emails per organisation. + * + * Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a + * result object so callers can choose to silently drop (job path) or throw + * (sync path). + * + * Remove this helper and all callers when the comprehensive abuse-prevention + * design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md + */ +export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise => { + // Self-hosted instances are not behind the SES cap. + if (!IS_BILLING_ENABLED()) { + return { allowed: true }; + } + + const ip = `org:${options.organisationId}`; + + const fiveMinResult = await recipientEmailRateLimit5m.check({ ip }); + if (fiveMinResult.isLimited) { + return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset }; + } + + const dailyResult = await recipientEmailRateLimit1d.check({ ip }); + if (dailyResult.isLimited) { + return { allowed: false, reason: '1d', resetsAt: dailyResult.reset }; + } + + return { allowed: true }; +}; diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index b86746226..4e1ae3dba 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -66,6 +66,7 @@ export type EmailContextResponse = { branding: BrandingSettings; settings: Omit; claims: OrganisationClaim; + organisationId: string; organisationType: OrganisationType; senderEmail: { name: string; @@ -164,6 +165,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => { ), settings: organisation.organisationGlobalSettings, claims, + organisationId: organisation.id, organisationType: organisation.type, }; }; @@ -208,6 +210,7 @@ const handleTeamEmailContext = async (teamId: number) => { branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false), settings: teamSettings, claims, + organisationId: organisation.id, organisationType: organisation.type, }; }; diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index a067546a4..d2cb2239e 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -97,3 +97,17 @@ export const fileUploadRateLimit = createRateLimit({ max: 20, window: '1m', }); + +// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ---- + +export const recipientEmailRateLimit5m = createRateLimit({ + action: 'email.send.recipient.5m', + max: 100, + window: '5m', +}); + +export const recipientEmailRateLimit1d = createRateLimit({ + action: 'email.send.recipient.1d', + max: 1500, + window: '1d', +}); From 7e8da85bd8f6e283e07cfd8b902df750b6b91e53 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 28 May 2026 21:15:27 +0900 Subject: [PATCH 02/91] 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. --- apps/remix/app/components/forms/signup.tsx | 1 + package-lock.json | 10 +++++ .../auth/server/lib/errors/error-codes.ts | 1 + .../lib/utils/handle-oauth-callback-url.ts | 15 ++++++- .../handle-oauth-organisation-callback-url.ts | 11 ++++- packages/auth/server/routes/email-password.ts | 12 +++++- packages/lib/constants/auth.ts | 43 +++++++++++++++++++ packages/lib/package.json | 1 + 8 files changed, 91 insertions(+), 3 deletions(-) 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", From a84da2f2c79c1f0b5f23a4040a2b9e36cb77757c Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 28 May 2026 21:19:13 +0900 Subject: [PATCH 03/91] chore: disabled account enforcement (#2882) --- packages/auth/server/lib/session/session.ts | 3 +- packages/auth/server/lib/utils/authorizer.ts | 8 ++++ packages/auth/server/routes/email-password.ts | 8 +--- .../server-only/document/resend-document.ts | 6 +++ .../lib/server-only/document/send-document.ts | 6 +++ .../server-only/envelope/create-envelope.ts | 6 +++ .../user/assert-user-not-disabled.ts | 48 +++++++++++++++++++ packages/trpc/server/trpc.ts | 42 ++++++++++++---- 8 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 packages/lib/server-only/user/assert-user-not-disabled.ts diff --git a/packages/auth/server/lib/session/session.ts b/packages/auth/server/lib/session/session.ts index 5e741406d..75d3830d7 100644 --- a/packages/auth/server/lib/session/session.ts +++ b/packages/auth/server/lib/session/session.ts @@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config'; */ export type SessionUser = Pick< User, - 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' + 'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled' >; export type SessionValidationResult = @@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise) => { + await assertUserNotDisabledById({ userId: user.userId }); + const metadata = c.get('requestMetadata'); const sessionToken = generateSessionToken(); diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 26beb0175..174fb9d7b 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -171,12 +171,8 @@ export const emailPasswordRoute = new Hono() }); } - if (user.disabled) { - throw new AppError('ACCOUNT_DISABLED', { - message: 'Account disabled', - }); - } - + // The disabled check now lives inside `onAuthorize` so every sign-in path + // (password, passkey, OAuth, OIDC) shares the same enforcement. await onAuthorize({ userId: user.id }, c); return c.text('', 201); diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 9b4ab968e..bb4fa7870 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -30,6 +30,7 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed'; import { getEmailContext } from '../email/get-email-context'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertUserNotDisabled } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type ResendDocumentOptions = { @@ -49,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe id: true, email: true, name: true, + disabled: true, }, }); + // Refuse to resend on behalf of a disabled account. Guards + // document.redistribute / envelope.redistribute and the API v1 equivalent. + assertUserNotDisabled(user); + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 09997e0c3..01fcffa1b 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -39,6 +39,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; +import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type SendDocumentOptions = { @@ -50,6 +51,11 @@ export type SendDocumentOptions = { }; export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => { + // Refuse to send on behalf of a disabled account. Guards distribute / + // redistribute / template-use routes, the bulk-send job, and direct + // templates that auto-send on creation. + await assertUserNotDisabledById({ userId }); + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id, type: EnvelopeType.DOCUMENT, diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 37c221b90..f228a635e 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -37,6 +37,7 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { getTeamSettings } from '../team/get-team-settings'; +import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & { @@ -116,6 +117,11 @@ export const createEnvelope = async ({ internalVersion, bypassDefaultRecipients = false, }: CreateEnvelopeOptions) => { + // Refuse to create on behalf of a disabled account. Guards every route that + // funnels through here (document.create, envelope.use, template create, + // embedding template/document create, API v1) and the seed/job paths. + await assertUserNotDisabledById({ userId }); + const { type, title, diff --git a/packages/lib/server-only/user/assert-user-not-disabled.ts b/packages/lib/server-only/user/assert-user-not-disabled.ts new file mode 100644 index 000000000..d4e789da8 --- /dev/null +++ b/packages/lib/server-only/user/assert-user-not-disabled.ts @@ -0,0 +1,48 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +/** + * Throws if the supplied user object is disabled. + * + * Synchronous variant for hot paths where the `disabled` field has already + * been loaded (e.g. TRPC middleware where the user comes from the session + * query or API token lookup). + */ +export const assertUserNotDisabled = (user: { disabled: boolean }): void => { + if (user.disabled) { + throw new AppError('ACCOUNT_DISABLED', { + message: 'Account disabled', + statusCode: 403, + }); + } +}; + +export type AssertUserNotDisabledByIdOptions = { + userId: number; +}; + +/** + * Throws if the user with the given id does not exist or is disabled. + * + * Used as a defence-in-depth guard for sign-in chokepoints and server-side + * actions that should not be performed on behalf of a disabled account + * (e.g. creating or sending documents). It deliberately re-queries from the + * database rather than relying on cached context so a freshly-disabled user + * cannot continue to act through a stale session or token. + */ +export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise => { + const user = await prisma.user.findFirst({ + where: { id: userId }, + select: { disabled: true }, + }); + + if (!user) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'User not found', + statusCode: 404, + }); + } + + assertUserNotDisabled(user); +}; diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 00da2bbc9..2d2f8673c 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -1,5 +1,6 @@ import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; +import { assertUserNotDisabled } from '@documenso/lib/server-only/user/assert-user-not-disabled'; import type { TrpcApiLog } from '@documenso/lib/types/api-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { alphaid } from '@documenso/lib/universal/id'; @@ -96,6 +97,10 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me const apiToken = await getApiTokenByToken({ token }); + // Reject API requests from a disabled account. The token may still be + // present in the DB (e.g. before `disableUser` runs) so we enforce here. + assertUserNotDisabled(apiToken.user); + const trpcApiV2Logger = ctx.logger.child({ ...baseLogAttributes, auth: 'api', @@ -140,6 +145,11 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path, me }); } + // Reject session requests from a disabled account. The session may still be + // valid (sessions aren't invalidated by `disableUser`), so we gate every + // authenticated TRPC call here. + assertUserNotDisabled(ctx.user); + // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line // (including errors) inherits them. @@ -199,6 +209,11 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat const apiToken = await getApiTokenByToken({ token }); + // Reject API requests from a disabled account. Presenting an API token is + // an explicit attempt to act under that account, so we don't downgrade to + // anonymous here — we reject. + assertUserNotDisabled(apiToken.user); + // Attach identifying attributes to the logger so every subsequent log line // within this request (including errors) inherits them. const trpcApiV2Logger = ctx.logger.child({ @@ -238,9 +253,17 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat }); } + // Treat a disabled session as anonymous. Most routes wired through + // `maybeAuthenticatedProcedure` are signer/invite flows that key off an + // input token rather than `ctx.user`, so downgrading lets those keep + // working while routes that genuinely need an account naturally fall + // through to their own auth checks. + const sessionUser = ctx.user && !ctx.user.disabled ? ctx.user : null; + const sessionRecord = sessionUser ? ctx.session : null; + // Resolve `auth` once so it stays in sync between the logger bindings and // the outgoing metadata. - const auth = ctx.session ? 'session' : null; + const auth = sessionRecord ? 'session' : null; // Recreate the logger with a sub request ID to differentiate between batched // requests, as well as identifying attributes so every subsequent log line @@ -249,7 +272,7 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat ...baseLogAttributes, auth, nonBatchedRequestId: alphaid(), - userId: ctx.user?.id, + userId: sessionUser?.id, apiTokenId: null, } satisfies TrpcApiLog); @@ -261,15 +284,15 @@ export const maybeAuthenticatedMiddleware = t.middleware(async ({ ctx, next, pat ctx: { ...ctx, logger: trpcSessionLogger, - user: ctx.user, - session: ctx.session, + user: sessionUser, + session: sessionRecord, metadata: { ...ctx.metadata, - auditUser: ctx.user + auditUser: sessionUser ? { - id: ctx.user.id, - name: ctx.user.name, - email: ctx.user.email, + id: sessionUser.id, + name: sessionUser.name, + email: sessionUser.email, } : undefined, auth, @@ -286,6 +309,9 @@ export const adminMiddleware = t.middleware(async ({ ctx, next, path }) => { }); } + // Disabled admins shouldn't be able to do anything either. + assertUserNotDisabled(ctx.user); + const isUserAdmin = isAdmin(ctx.user); if (!isUserAdmin) { From 22ceff43e32ee9c929b324db9287795a669a9148 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 29 May 2026 00:12:55 +0900 Subject: [PATCH 04/91] feat: admin-configurable email blocklist (#2884) --- .../general/admin-email-blocklist-section.tsx | 171 +++++++++++++++ .../general/admin-site-banner-section.tsx | 197 +++++++++++++++++ .../_authenticated+/admin+/site-settings.tsx | 202 ++---------------- .../lib/utils/handle-oauth-callback-url.ts | 5 +- .../handle-oauth-organisation-callback-url.ts | 5 +- packages/auth/server/routes/email-password.ts | 5 +- packages/lib/constants/auth.ts | 10 +- .../get-email-blocklist-domains.ts | 31 +++ .../lib/server-only/site-settings/schema.ts | 7 +- .../site-settings/schemas/email-blocklist.ts | 29 +++ 10 files changed, 468 insertions(+), 194 deletions(-) create mode 100644 apps/remix/app/components/general/admin-email-blocklist-section.tsx create mode 100644 apps/remix/app/components/general/admin-site-banner-section.tsx create mode 100644 packages/lib/server-only/site-settings/get-email-blocklist-domains.ts create mode 100644 packages/lib/server-only/site-settings/schemas/email-blocklist.ts diff --git a/apps/remix/app/components/general/admin-email-blocklist-section.tsx b/apps/remix/app/components/general/admin-email-blocklist-section.tsx new file mode 100644 index 000000000..7d074621a --- /dev/null +++ b/apps/remix/app/components/general/admin-email-blocklist-section.tsx @@ -0,0 +1,171 @@ +import { + SITE_SETTINGS_EMAIL_BLOCKLIST_ID, + type TSiteSettingsEmailBlocklistSchema, +} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { useRevalidator } from 'react-router'; +import { z } from 'zod'; + +const ZEmailBlocklistFormSchema = z.object({ + enabled: z.boolean(), + domains: z.string(), +}); + +type TEmailBlocklistFormSchema = z.infer; + +/** + * Splits a comma-separated string into a normalised list of domains. + * Normalisation (trim, lowercase, strip leading "@", dedupe) is applied + * server-side by the schema as well — this is for display consistency. + */ +const parseDomainsInput = (value: string): string[] => { + return Array.from( + new Set( + value + .split(',') + .map((entry) => entry.trim().toLowerCase().replace(/^@/, '')) + .filter((entry) => entry.length > 0), + ), + ); +}; + +type AdminEmailBlocklistSectionProps = { + emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined; +}; + +export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => { + const { toast } = useToast(); + const { _ } = useLingui(); + const { revalidate } = useRevalidator(); + + const form = useForm({ + resolver: zodResolver(ZEmailBlocklistFormSchema), + defaultValues: { + enabled: emailBlocklist?.enabled ?? false, + domains: (emailBlocklist?.data?.domains ?? []).join(', '), + }, + }); + + const enabled = form.watch('enabled'); + + const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } = + trpcReact.admin.updateSiteSetting.useMutation(); + + const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => { + try { + const parsedDomains = parseDomainsInput(domains); + + await updateSiteSetting({ + id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID, + enabled, + data: { + domains: parsedDomains, + }, + }); + + // Reflect the normalised value back in the form. + form.reset({ + enabled, + domains: parsedDomains.join(', '), + }); + + toast({ + title: _(msg`Email Blocklist Updated`), + description: _(msg`The email blocklist has been updated successfully.`), + duration: 5000, + }); + + await revalidate(); + } catch (err) { + toast({ + title: _(msg`An unknown error occurred`), + variant: 'destructive', + description: _( + msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`, + ), + }); + } + }; + + return ( +
+

+ Email Blocklist +

+

+ + Block signups from additional email domains on top of the bundled disposable email list. Subdomains are + matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com"). + +

+ +
+ + ( + + + Enabled + + + +
+ +
+
+
+ )} + /> + +
+ ( + + + Blocked Domains + + + +