Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/app/api/auth/magic-link/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof findUserByEmail>>);

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');
});
});
});
9 changes: 9 additions & 0 deletions src/components/auth/AuthErrorNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ export function AuthErrorNotification({ error }: { error: string }) {
</div>
);

if (error === 'BLOCKED-TLD')
return (
<div data-error-notification>
<ErrorNotificationBox title="Unsupported Email Domain">
Signups from this email domain are not currently supported.
</ErrorNotificationBox>
</div>
);

return (
<div data-error-notification>
<ErrorNotificationBox title="Error">
Expand Down
14 changes: 13 additions & 1 deletion src/hooks/useSignInFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type AuthErrorType =
| 'BLOCKED'
| 'BLOCKED-TLD'
| 'DIFFERENT-OAUTH'
| 'ACCOUNT-ALREADY-LINKED'
| 'PROVIDER-ALREADY-LINKED'
Expand Down
96 changes: 96 additions & 0 deletions src/lib/schemas/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
validateMagicLinkSignupEmail,
magicLinkSignupEmailSchema,
MAGIC_LINK_EMAIL_ERRORS,
hasBlockedTLD,
BLOCKED_SIGNUP_TLDS,
} from './email';

describe('validateMagicLinkSignupEmail', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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']);
});
});
22 changes: 22 additions & 0 deletions src/lib/schemas/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -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 };
}

Expand All @@ -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.',
});
10 changes: 10 additions & 0 deletions src/lib/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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') {
Expand Down
Loading