Skip to content

Commit dc19342

Browse files
committed
improvement(auth): tighten the disposable-email signup gate and cover its failure modes
1 parent 1559a18 commit dc19342

4 files changed

Lines changed: 67 additions & 5 deletions

File tree

‎apps/sim/app/oauth-error/page.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ const FRIENDLY: Record<string, string> = {
5252
*/
5353
[SSO_REQUIRED_ERROR_CODE]: SSO_REQUIRED_MESSAGE,
5454
/**
55-
* Account creation refused for a disposable email domain. Better Auth 1.7 redirects with the
56-
* error's code; 1.6 redirects with its message, spaces joined by underscores.
55+
* A disposable email domain refused at account creation. Better Auth 1.7 redirects with the
56+
* error's code, 1.6 with its message slugged (spaces to underscores), so both keys map here.
5757
*/
5858
[DISPOSABLE_EMAIL_ERROR_CODE]: DISPOSABLE_EMAIL_MESSAGE,
59-
[DISPOSABLE_EMAIL_MESSAGE.split(' ').join('_')]: DISPOSABLE_EMAIL_MESSAGE,
59+
[DISPOSABLE_EMAIL_MESSAGE.replaceAll(' ', '_')]: DISPOSABLE_EMAIL_MESSAGE,
6060
/** The provider returned no email claim, so there is nothing to sign in as. */
6161
email_not_found:
6262
'Your identity provider didn’t share an email address with us, so we couldn’t complete sign-in. Please contact your administrator.',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ export const auth = betterAuth({
362362
before: async (user) => {
363363
const accessControl = await getAccessControlConfig()
364364
if (isEmailBlockedByAccessControl(user.email, accessControl)) {
365+
// Plain Error, so OAuth shows only a generic failure, not that this email is blocked.
365366
throw new Error('Sign-ups from this email are not allowed.')
366367
}
367368
if (isHosted && (await isDisposableEmail(user.email))) {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
const DOMAINS_MODULE = '@/lib/messaging/email/disposable-email-domains.json'
4+
5+
async function loadChecker() {
6+
vi.resetModules()
7+
return (await import('@/lib/messaging/email/disposable')).isDisposableEmail
8+
}
9+
10+
afterEach(() => {
11+
vi.doUnmock(DOMAINS_MODULE)
12+
})
13+
14+
describe('isDisposableEmail', () => {
15+
it('refuses a listed domain and any subdomain of it, whatever the case or padding', async () => {
16+
const isDisposableEmail = await loadChecker()
17+
18+
expect(await isDisposableEmail('a@mailinator.com')).toBe(true)
19+
expect(await isDisposableEmail('a@inbox.mailinator.com')).toBe(true)
20+
expect(await isDisposableEmail(' A@MailInator.COM ')).toBe(true)
21+
})
22+
23+
it('allows mainstream providers and mail relays, including relay aliases upstream lists', async () => {
24+
const isDisposableEmail = await loadChecker()
25+
26+
for (const email of [
27+
'a@gmail.com',
28+
'a@outlook.com',
29+
'a@proton.me',
30+
'a@addy.to',
31+
'a@someone.mozmail.com',
32+
'a@someone.anonaddy.com',
33+
'a@privaterelay.appleid.com',
34+
]) {
35+
expect(await isDisposableEmail(email), email).toBe(false)
36+
}
37+
})
38+
39+
it('allows an address with no domain instead of throwing', async () => {
40+
const isDisposableEmail = await loadChecker()
41+
42+
for (const email of [undefined, null, '', 'no-at-sign', 'a@']) {
43+
expect(await isDisposableEmail(email)).toBe(false)
44+
}
45+
})
46+
47+
it('allows the signup when the list cannot load, and loads it on the next call', async () => {
48+
let failLoad = true
49+
vi.doMock(DOMAINS_MODULE, () => {
50+
if (failLoad) throw new Error('chunk load failed')
51+
return { default: ['mailinator.com'] }
52+
})
53+
const isDisposableEmail = await loadChecker()
54+
55+
expect(await isDisposableEmail('a@mailinator.com')).toBe(false)
56+
57+
failLoad = false
58+
expect(await isDisposableEmail('a@mailinator.com')).toBe(true)
59+
})
60+
})

‎apps/sim/lib/messaging/email/disposable.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { normalizeEmail } from '@sim/utils/string'
23

34
const logger = createLogger('DisposableEmail')
45

@@ -66,11 +67,11 @@ function loadDisposableDomains(): Promise<ReadonlySet<string>> {
6667
* mainstream providers (Gmail, Outlook, iCloud, Proton), none of which belong here.
6768
*/
6869
export async function isDisposableEmail(email: string | null | undefined): Promise<boolean> {
69-
const domain = email?.split('@')[1]?.trim().toLowerCase()
70+
const domain = email ? normalizeEmail(email).split('@')[1] : undefined
7071
if (!domain) return false
7172

7273
const labels = domain.split('.')
73-
const candidates = labels.slice(0, -1).map((_, i) => labels.slice(i).join('.'))
74+
const candidates = Array.from({ length: labels.length - 1 }, (_, i) => labels.slice(i).join('.'))
7475
if (candidates.some((candidate) => MAIL_RELAY_DOMAINS.has(candidate))) return false
7576

7677
let domains: ReadonlySet<string>

0 commit comments

Comments
 (0)