Skip to content

Commit 962526f

Browse files
waleedlatif1claude
andauthored
fix(auth): block only curated disposable domains, and never gate SSO or SCIM provisioning (#9529)
* fix(auth): block only curated disposable domains, and never gate SSO or SCIM provisioning * chore(auth): rename disposable list script to generate:, read SSO callback paths from the plugin Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2dfdc37 commit 962526f

10 files changed

Lines changed: 327 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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
import { configuredSsoPlugin } from '@/lib/auth/sso/plugin'
8+
9+
/**
10+
* A real Better Auth instance wired the way `auth.ts` wires the gate, so the endpoint path each
11+
* creation path reports to `databaseHooks.user.create.before` is Better Auth's, not an assumption.
12+
*/
13+
function createAuth() {
14+
return betterAuth({
15+
baseURL: 'http://localhost:3000',
16+
secret: 'disposable-email-gate-test-secret-0123456789',
17+
database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
18+
emailAndPassword: { enabled: true },
19+
plugins: [admin()],
20+
databaseHooks: {
21+
user: {
22+
create: {
23+
before: async (user, context) => {
24+
await refuseDisposableAccountEmail(user.email, context?.path)
25+
return { data: user }
26+
},
27+
},
28+
},
29+
},
30+
})
31+
}
32+
33+
const password = 'correct-horse-battery-staple'
34+
35+
describe('refuseDisposableAccountEmail', () => {
36+
it('refuses a self-service email signup from a disposable domain', async () => {
37+
const auth = createAuth()
38+
await expect(
39+
auth.api.signUpEmail({ body: { email: 'jane@mailinator.com', password, name: 'Jane' } })
40+
).rejects.toMatchObject({ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } })
41+
})
42+
43+
it('admits a self-service email signup from a company under a public suffix', async () => {
44+
const auth = createAuth()
45+
const result = await auth.api.signUpEmail({
46+
body: { email: 'jane@company.msk.ru', password, name: 'Jane' },
47+
})
48+
expect(result.user.email).toBe('jane@company.msk.ru')
49+
})
50+
51+
/** SCIM provisioning creates accounts through the admin plugin's server-side `createUser`. */
52+
it('admits a directory-provisioned account whatever its domain', async () => {
53+
const auth = createAuth()
54+
const result = await auth.api.createUser({
55+
body: { email: 'jane@mailinator.com', name: 'Jane' },
56+
})
57+
expect(result.user.email).toBe('jane@mailinator.com')
58+
})
59+
60+
/**
61+
* Better Auth dispatches with an endpoint's declared path, so the SSO plugin's own callback
62+
* endpoints name the paths a just-in-time creation reports. No IdP is driven here.
63+
*/
64+
const { callbackSSO, callbackSSOShared, callbackSSOSAML, acsEndpoint } =
65+
configuredSsoPlugin.endpoints
66+
it.each([callbackSSO.path, callbackSSOShared.path, callbackSSOSAML.path, acsEndpoint.path])(
67+
'admits any domain at the SSO plugin callback path %s',
68+
async (path) => {
69+
await expect(
70+
refuseDisposableAccountEmail('jane@mailinator.com', path)
71+
).resolves.toBeUndefined()
72+
}
73+
)
74+
75+
it.each([undefined, '/sign-up/email', '/callback/:id', '/oauth2/callback/:providerId'])(
76+
'refuses a disposable domain created at %s',
77+
async (path) => {
78+
await expect(refuseDisposableAccountEmail('jane@mailinator.com', path)).rejects.toMatchObject(
79+
{ body: { code: DISPOSABLE_EMAIL_ERROR_CODE } }
80+
)
81+
}
82+
)
83+
})
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)