Skip to content

Commit b0c26b2

Browse files
committed
fix(auth): block only curated disposable domains, and never gate SSO or SCIM provisioning
1 parent 3e23d39 commit b0c26b2

10 files changed

Lines changed: 320 additions & 88666 deletions

File tree

‎apps/sim/app/(auth)/signup/signup-form.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { usePostHog } from 'posthog-js/react'
88
import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
99
import { trackGoogleEvent } from '@/lib/analytics/google'
1010
import { client, useSession } from '@/lib/auth/client'
11+
import { DISPOSABLE_EMAIL_ERROR_CODE, DISPOSABLE_EMAIL_MESSAGE } from '@/lib/auth/constants'
1112
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
1213
import { getEnv, isFalsy } from '@/lib/core/config/env'
1314
import { isSsoEnabled } from '@/lib/core/config/env-flags'
@@ -301,6 +302,9 @@ function SignupFormContent({
301302
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
302303
errorCode = 'user_already_exists'
303304
setEmailError('An account with this email already exists. Please sign in instead.')
305+
} else if (ctx.error.code === DISPOSABLE_EMAIL_ERROR_CODE) {
306+
errorCode = 'disposable_email'
307+
setEmailError(DISPOSABLE_EMAIL_MESSAGE)
304308
} else if (
305309
ctx.error.code?.includes('BAD_REQUEST') ||
306310
ctx.error.message?.includes('Email and password sign up is not enabled')

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,11 @@ import { stableExternalAccountId } from '@/lib/auth/connectors/account-id'
4545
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
4646
import {
4747
applyRegistrationGate,
48-
DISPOSABLE_EMAIL_ERROR_CODE,
49-
DISPOSABLE_EMAIL_MESSAGE,
5048
getRequestedSignInProviderId,
5149
isSignInProviderAllowed,
5250
} from '@/lib/auth/constants'
5351
import { getAuthDatabase } from '@/lib/auth/database-context'
52+
import { refuseDisposableAccountEmail } from '@/lib/auth/disposable-email-gate'
5453
import {
5554
type AuthenticationSecurityAuditSnapshot,
5655
captureAuthenticationSecurityAudit,
@@ -153,7 +152,6 @@ import {
153152
processCredentialDraft,
154153
refuseConflictingCredentialReconnect,
155154
} from '@/lib/credentials/draft-processor'
156-
import { isDisposableEmail } from '@/lib/messaging/email/disposable'
157155
import { sendEmail } from '@/lib/messaging/email/mailer'
158156
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
159157
import { quickValidateEmail } from '@/lib/messaging/email/validation'
@@ -359,20 +357,12 @@ export const auth = betterAuth({
359357
databaseHooks: {
360358
user: {
361359
create: {
362-
before: async (user) => {
360+
before: async (user, context) => {
363361
const accessControl = await getAccessControlConfig()
364362
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
365363
throw new Error('Sign-ups from this email are not allowed.')
366364
}
367-
if (isHosted && (await isDisposableEmail(user.email))) {
368-
logger.warn('Refusing account creation for a disposable email domain', {
369-
domain: user.email.split('@')[1],
370-
})
371-
throw new APIError('FORBIDDEN', {
372-
code: DISPOSABLE_EMAIL_ERROR_CODE,
373-
message: DISPOSABLE_EMAIL_MESSAGE,
374-
})
375-
}
365+
if (isHosted) await refuseDisposableAccountEmail(user.email, context?.path)
376366
return { data: user }
377367
},
378368
after: async (user, context) => {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { betterAuth } from 'better-auth'
2+
import { memoryAdapter } from 'better-auth/adapters/memory'
3+
import { admin } from 'better-auth/plugins'
4+
import { describe, expect, it } from 'vitest'
5+
import { DISPOSABLE_EMAIL_ERROR_CODE } from '@/lib/auth/constants'
6+
import { refuseDisposableAccountEmail } from '@/lib/auth/disposable-email-gate'
7+
8+
/**
9+
* A real Better Auth instance wired the way `auth.ts` wires the gate, so the endpoint path each
10+
* creation path reports to `databaseHooks.user.create.before` is Better Auth's, not an assumption.
11+
*/
12+
function createAuth() {
13+
return betterAuth({
14+
baseURL: 'http://localhost:3000',
15+
secret: 'disposable-email-gate-test-secret-0123456789',
16+
database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
17+
emailAndPassword: { enabled: true },
18+
plugins: [admin()],
19+
databaseHooks: {
20+
user: {
21+
create: {
22+
before: async (user, context) => {
23+
await refuseDisposableAccountEmail(user.email, context?.path)
24+
return { data: user }
25+
},
26+
},
27+
},
28+
},
29+
})
30+
}
31+
32+
const password = 'correct-horse-battery-staple'
33+
34+
describe('refuseDisposableAccountEmail', () => {
35+
it('refuses a self-service email signup from a disposable domain', async () => {
36+
const auth = createAuth()
37+
await expect(
38+
auth.api.signUpEmail({ body: { email: 'jane@mailinator.com', password, name: 'Jane' } })
39+
).rejects.toMatchObject({ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } })
40+
})
41+
42+
it('admits a self-service email signup from a company under a public suffix', async () => {
43+
const auth = createAuth()
44+
const result = await auth.api.signUpEmail({
45+
body: { email: 'jane@company.msk.ru', password, name: 'Jane' },
46+
})
47+
expect(result.user.email).toBe('jane@company.msk.ru')
48+
})
49+
50+
/** SCIM provisioning creates accounts through the admin plugin's server-side `createUser`. */
51+
it('admits a directory-provisioned account whatever its domain', async () => {
52+
const auth = createAuth()
53+
const result = await auth.api.createUser({
54+
body: { email: 'jane@mailinator.com', name: 'Jane' },
55+
})
56+
expect(result.user.email).toBe('jane@mailinator.com')
57+
})
58+
59+
it.each([
60+
'/sso/callback/:providerId',
61+
'/sso/callback',
62+
'/sso/saml2/callback/:providerId',
63+
'/sso/saml2/sp/acs/:providerId',
64+
])('admits an SSO just-in-time account created at %s', async (path) => {
65+
await expect(refuseDisposableAccountEmail('jane@mailinator.com', path)).resolves.toBeUndefined()
66+
})
67+
68+
it.each([undefined, '/sign-up/email', '/callback/:id', '/oauth2/callback/:providerId'])(
69+
'refuses a disposable domain created at %s',
70+
async (path) => {
71+
await expect(refuseDisposableAccountEmail('jane@mailinator.com', path)).rejects.toMatchObject(
72+
{ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } }
73+
)
74+
}
75+
)
76+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { APIError } from 'better-auth/api'
3+
import { DISPOSABLE_EMAIL_ERROR_CODE, DISPOSABLE_EMAIL_MESSAGE } from '@/lib/auth/constants'
4+
import { isSsoCallbackPath } from '@/lib/auth/sso/callback-provider'
5+
import { emailDomain, isDisposableEmail } from '@/lib/messaging/email/disposable'
6+
7+
const logger = createLogger('DisposableEmailGate')
8+
9+
/**
10+
* The admin plugin's endpoint, which SCIM provisioning calls server-side. Over HTTP it requires a
11+
* platform admin's session, so either way an account created here was not self-service.
12+
*/
13+
const ADMIN_CREATE_USER_PATH = '/admin/create-user'
14+
15+
/**
16+
* Whether the account is being created on behalf of an organization: SCIM provisioning, or
17+
* just-in-time creation at an SSO callback (Sim admits SSO only through a provider whose domain
18+
* the organization verified). The organization vouches for the identity, so a throwaway inbox is
19+
* not the risk, while a false positive breaks provisioning (a refused SCIM create is retried by
20+
* the IdP indefinitely).
21+
*/
22+
function isOrganizationProvisioned(path: string | undefined): boolean {
23+
return path !== undefined && (path === ADMIN_CREATE_USER_PATH || isSsoCallbackPath(path))
24+
}
25+
26+
/**
27+
* Refuses self-service account creation from a disposable email domain, throwing the
28+
* `DISPOSABLE_EMAIL` error the signup form and OAuth error page recognize.
29+
*
30+
* @param path - the Better Auth endpoint the creation runs under (`context?.path` in
31+
* `databaseHooks.user.create.before`), which tells organization provisioning apart from signup.
32+
*/
33+
export async function refuseDisposableAccountEmail(
34+
email: string,
35+
path: string | undefined
36+
): Promise<void> {
37+
if (isOrganizationProvisioned(path)) return
38+
if (!(await isDisposableEmail(email))) return
39+
40+
logger.warn('Refusing account creation for a disposable email domain', {
41+
domain: emailDomain(email),
42+
path,
43+
})
44+
throw new APIError('FORBIDDEN', {
45+
code: DISPOSABLE_EMAIL_ERROR_CODE,
46+
message: DISPOSABLE_EMAIL_MESSAGE,
47+
})
48+
}

0 commit comments

Comments
 (0)