diff --git a/src/app/api/auth/magic-link/route.test.ts b/src/app/api/auth/magic-link/route.test.ts index f2a0b1802..6a7b7d7d6 100644 --- a/src/app/api/auth/magic-link/route.test.ts +++ b/src/app/api/auth/magic-link/route.test.ts @@ -196,5 +196,52 @@ describe('POST /api/auth/magic-link', () => { expect(data.success).toBe(true); expect(mockCreateMagicLinkToken).toHaveBeenCalledWith('user@example.com'); }); + + it('should reject .shop TLD email for new users', async () => { + mockFindUserByEmail.mockResolvedValue(undefined); // New user + + const response = await POST(createRequest({ email: 'user@example.shop' })); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ success: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + expect(mockCreateMagicLinkToken).not.toHaveBeenCalled(); + }); + + it('should reject .top TLD email for new users', async () => { + mockFindUserByEmail.mockResolvedValue(undefined); // New user + + const response = await POST(createRequest({ email: 'user@example.top' })); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ success: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + expect(mockCreateMagicLinkToken).not.toHaveBeenCalled(); + }); + + it('should reject .xyz TLD email for new users', async () => { + mockFindUserByEmail.mockResolvedValue(undefined); // New user + + const response = await POST(createRequest({ email: 'user@example.xyz' })); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ success: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + expect(mockCreateMagicLinkToken).not.toHaveBeenCalled(); + }); + + it('should allow blocked TLD email for existing users (sign-in)', async () => { + mockFindUserByEmail.mockResolvedValue({ + id: 'existing-user-id', + google_user_email: 'user@example.shop', + } as Awaited>); + + const response = await POST(createRequest({ email: 'user@example.shop' })); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockCreateMagicLinkToken).toHaveBeenCalledWith('user@example.shop'); + }); }); }); diff --git a/src/components/auth/AuthErrorNotification.tsx b/src/components/auth/AuthErrorNotification.tsx index e4f3d8d7e..64d9a94e1 100644 --- a/src/components/auth/AuthErrorNotification.tsx +++ b/src/components/auth/AuthErrorNotification.tsx @@ -59,6 +59,15 @@ export function AuthErrorNotification({ error }: { error: string }) { ); + if (error === 'BLOCKED-TLD') + return ( +
+ + Signups from this email domain are not currently supported. + +
+ ); + return (
diff --git a/src/hooks/useSignInFlow.ts b/src/hooks/useSignInFlow.ts index d4ca89ba6..96c4a40d2 100644 --- a/src/hooks/useSignInFlow.ts +++ b/src/hooks/useSignInFlow.ts @@ -7,7 +7,7 @@ import { captureException } from '@sentry/nextjs'; import type { AuthProviderId } from '@/lib/auth/provider-metadata'; import { ProdNonSSOAuthProviders } from '@/lib/auth/provider-metadata'; import { useSignInHint, type SignInHint } from '@/hooks/useSignInHint'; -import { emailSchema, validateMagicLinkSignupEmail } from '@/lib/schemas/email'; +import { emailSchema, validateMagicLinkSignupEmail, hasBlockedTLD } from '@/lib/schemas/email'; import { sendMagicLink } from '@/lib/auth/send-magic-link'; import type { SSOOrganizationsResponse } from '@/lib/schemas/sso-organizations'; @@ -198,6 +198,10 @@ export function useSignInFlow({ error: result.error.issues[0]?.message || 'Invalid email', }; } + // Block signups from abusive TLDs across all providers + if (isSignUp && hasBlockedTLD(email)) { + return { isValid: false, error: 'Signups from this email domain are not currently supported.' }; + } // For signup pages with magic link selected, validate email restrictions // Only show this on explicit signup pages (isSignUp=true) when user has selected email provider // Don't show on sign-in pages to avoid confusing existing users @@ -290,6 +294,14 @@ export function useSignInFlow({ let providersToShow: AuthProviderId[]; if (isNewUser) { + // Block new signups from abusive TLDs entirely + if (hasBlockedTLD(email)) { + setIsVerifying(false); + setShowTurnstile(false); + setError('Signups from this email domain are not currently supported.'); + setFlowState('landing'); + return; + } // New user: show all available providers (they can choose any to create account) providersToShow = [...ProdNonSSOAuthProviders]; // For new users (signup), filter out magic link if email is invalid diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index 900baa3df..ce2b9bf83 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -1,5 +1,6 @@ export type AuthErrorType = | 'BLOCKED' + | 'BLOCKED-TLD' | 'DIFFERENT-OAUTH' | 'ACCOUNT-ALREADY-LINKED' | 'PROVIDER-ALREADY-LINKED' diff --git a/src/lib/schemas/email.test.ts b/src/lib/schemas/email.test.ts index d1268a9fd..cc9c7ddd1 100644 --- a/src/lib/schemas/email.test.ts +++ b/src/lib/schemas/email.test.ts @@ -3,6 +3,8 @@ import { validateMagicLinkSignupEmail, magicLinkSignupEmailSchema, MAGIC_LINK_EMAIL_ERRORS, + hasBlockedTLD, + BLOCKED_SIGNUP_TLDS, } from './email'; describe('validateMagicLinkSignupEmail', () => { @@ -43,6 +45,26 @@ describe('validateMagicLinkSignupEmail', () => { const result = validateMagicLinkSignupEmail('User+tag@kilocode.ai'); expect(result).toEqual({ valid: false, error: MAGIC_LINK_EMAIL_ERRORS.LOWERCASE }); }); + + it('should reject email with blocked TLD .shop', () => { + const result = validateMagicLinkSignupEmail('user@example.shop'); + expect(result).toEqual({ valid: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + }); + + it('should reject email with blocked TLD .top', () => { + const result = validateMagicLinkSignupEmail('user@example.top'); + expect(result).toEqual({ valid: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + }); + + it('should reject email with blocked TLD .xyz', () => { + const result = validateMagicLinkSignupEmail('user@example.xyz'); + expect(result).toEqual({ valid: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }); + }); + + it('should not reject email with allowed TLD', () => { + const result = validateMagicLinkSignupEmail('user@example.com'); + expect(result).toEqual({ valid: true, error: null }); + }); }); describe('magicLinkSignupEmailSchema', () => { @@ -85,4 +107,78 @@ describe('magicLinkSignupEmailSchema', () => { expect(result.error.issues[0].message).toBe('Email address cannot contain a + character'); } }); + + it('should reject email with blocked TLD .shop', () => { + const result = magicLinkSignupEmailSchema.safeParse('user@example.shop'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Signups from this email domain are not currently supported.' + ); + } + }); + + it('should reject email with blocked TLD .top', () => { + const result = magicLinkSignupEmailSchema.safeParse('user@example.top'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Signups from this email domain are not currently supported.' + ); + } + }); + + it('should reject email with blocked TLD .xyz', () => { + const result = magicLinkSignupEmailSchema.safeParse('user@example.xyz'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Signups from this email domain are not currently supported.' + ); + } + }); +}); + +describe('hasBlockedTLD', () => { + it('should block .shop TLD', () => { + expect(hasBlockedTLD('user@example.shop')).toBe(true); + }); + + it('should block .top TLD', () => { + expect(hasBlockedTLD('user@example.top')).toBe(true); + }); + + it('should block .xyz TLD', () => { + expect(hasBlockedTLD('user@example.xyz')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(hasBlockedTLD('user@example.SHOP')).toBe(true); + expect(hasBlockedTLD('user@example.Top')).toBe(true); + expect(hasBlockedTLD('user@example.XYZ')).toBe(true); + }); + + it('should not block .com TLD', () => { + expect(hasBlockedTLD('user@example.com')).toBe(false); + }); + + it('should not block .org TLD', () => { + expect(hasBlockedTLD('user@example.org')).toBe(false); + }); + + it('should not block domains that merely contain a blocked TLD as a substring', () => { + // "workshop.com" contains "shop" but is not a .shop TLD + expect(hasBlockedTLD('user@workshop.com')).toBe(false); + // "laptop.com" contains "top" but is not a .top TLD + expect(hasBlockedTLD('user@laptop.com')).toBe(false); + }); + + it('should block subdomains of blocked TLDs', () => { + expect(hasBlockedTLD('user@mail.example.shop')).toBe(true); + expect(hasBlockedTLD('user@subdomain.example.top')).toBe(true); + }); + + it('should contain the expected TLDs', () => { + expect(BLOCKED_SIGNUP_TLDS).toEqual(['.shop', '.top', '.xyz']); + }); }); diff --git a/src/lib/schemas/email.ts b/src/lib/schemas/email.ts index a6f2dc32f..a6fb84c58 100644 --- a/src/lib/schemas/email.ts +++ b/src/lib/schemas/email.ts @@ -11,8 +11,15 @@ export const emailSchema = z.object({ export const MAGIC_LINK_EMAIL_ERRORS = { LOWERCASE: 'EMAIL-MUST-BE-LOWERCASE', NO_PLUS: 'EMAIL-CANNOT-CONTAIN-PLUS', + BLOCKED_TLD: 'BLOCKED-TLD', } as const; +/** + * TLDs blocked from new signups due to abuse. + * Add new entries here to block additional TLDs. + */ +export const BLOCKED_SIGNUP_TLDS = ['.shop', '.top', '.xyz'] as const; + /** * Domain that is allowed to use + in email addresses for internal testing. */ @@ -30,10 +37,19 @@ function isKilocodeDomain(email: string): boolean { return domain === KILOCODE_DOMAIN; } +/** + * Checks if an email uses a TLD that is blocked from new signups. + */ +export function hasBlockedTLD(email: string): boolean { + const lower = email.toLowerCase(); + return BLOCKED_SIGNUP_TLDS.some(tld => lower.endsWith(tld)); +} + /** * Validates that an email is suitable for magic link signup: * - Must be lowercase * - Must not contain a + character (except for @kilocode.ai emails) + * - Must not use a blocked TLD (.shop, .top, .xyz) * * This is NOT enforced during sign-in to existing accounts. * Returns error codes that can be displayed via AuthErrorNotification. @@ -48,6 +64,9 @@ export function validateMagicLinkSignupEmail(email: string): { if (email.includes('+') && !isKilocodeDomain(email)) { return { valid: false, error: MAGIC_LINK_EMAIL_ERRORS.NO_PLUS }; } + if (hasBlockedTLD(email)) { + return { valid: false, error: MAGIC_LINK_EMAIL_ERRORS.BLOCKED_TLD }; + } return { valid: true, error: null }; } @@ -58,4 +77,7 @@ export const magicLinkSignupEmailSchema = z }) .refine(email => !email.includes('+') || isKilocodeDomain(email), { message: 'Email address cannot contain a + character', + }) + .refine(email => !hasBlockedTLD(email), { + message: 'Signups from this email domain are not currently supported.', }); diff --git a/src/lib/user.server.ts b/src/lib/user.server.ts index f3507a91e..d1fe5098c 100644 --- a/src/lib/user.server.ts +++ b/src/lib/user.server.ts @@ -63,6 +63,7 @@ import type { UUID } from 'node:crypto'; import { logExceptInTest, sentryLogger } from '@/lib/utils.server'; import { processSSOUserLogin } from '@/lib/sso-user'; import { getLowerDomainFromEmail } from '@/lib/utils'; +import { hasBlockedTLD } from '@/lib/schemas/email'; import { z } from 'zod'; import { v5 as uuidv5 } from 'uuid'; @@ -443,6 +444,15 @@ const authOptions: NextAuthOptions = { } } + // Block new signups from abusive TLDs (existing users can still sign in) + if (!existingUser && hasBlockedTLD(accountInfo.google_user_email)) { + sentryLogger('auth', 'warning')( + `SECURITY: Blocked TLD signup: ${accountInfo.google_user_email}`, + accountInfo + ); + return redirectUrlForCode('BLOCKED-TLD', accountInfo.google_user_email); + } + // we don't need to check gmail domains for SSO for now. // This is mostly an optimization so we don't hit the DB on every gmail login since they defacto aren't using SSO if (domainToCheck !== 'gmail.com') {