Skip to content
Merged
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
10 changes: 9 additions & 1 deletion src/app/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { verifyTurnstileJWT } from '@/lib/auth/verify-turnstile-jwt';
import * as z from 'zod';
import { findUserByEmail } from '@/lib/user';
import { validateMagicLinkSignupEmail } from '@/lib/schemas/email';
import { isEmailBlacklistedByDomain, isBlockedTLD } from '@/lib/user.server';

const requestSchema = z.object({
email: z.string().email(),
Expand Down Expand Up @@ -36,11 +37,18 @@ export async function POST(request: NextRequest) {
return turnstileResult.response;
}

if (isEmailBlacklistedByDomain(email)) {
return NextResponse.json({ success: false, error: 'BLOCKED' }, { status: 403 });
}

// Check if this is an existing user (sign-in) or new user (signup)
const existingUser = await findUserByEmail(email);

// For new users, enforce stricter email validation
// For new users, enforce stricter email validation and TLD blocking
if (!existingUser) {
if (isBlockedTLD(email)) {
return NextResponse.json({ success: false, error: 'BLOCKED' }, { status: 403 });
}
const signupValidation = validateMagicLinkSignupEmail(email);
if (!signupValidation.valid) {
return NextResponse.json({ success: false, error: signupValidation.error }, { status: 400 });
Expand Down
9 changes: 9 additions & 0 deletions src/lib/config.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,12 @@ export const O11Y_KILO_GATEWAY_CLIENT_SECRET = getEnvVariable('O11Y_KILO_GATEWAY
export const SECURITY_CLEANUP_BETTERSTACK_HEARTBEAT_URL = getEnvVariable(
'SECURITY_CLEANUP_BETTERSTACK_HEARTBEAT_URL'
);

// Pipe-delimited list of TLDs to block from new signups, each with a leading dot (e.g. ".shop|.top|.co.uk")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're banning the UK?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, someone needs to put a stop to their questionable cuisine

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all seriousness, we are not blocking .co.uk but it was just an example the agent put in there

const blacklistTldsEnv = getEnvVariable('BLACKLIST_TLDS');
export const BLACKLIST_TLDS = blacklistTldsEnv
? blacklistTldsEnv
.split('|')
.map((tld: string) => tld.trim().toLowerCase())
.filter(Boolean)
: [];
48 changes: 48 additions & 0 deletions src/lib/user.server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, test, expect } from '@jest/globals';
import {
isEmailBlacklistedByDomain,
isBlockedTLD,
parseLinkedInProfileName,
getUserUUID,
uuidSchema,
Expand Down Expand Up @@ -90,6 +91,53 @@ describe('isEmailBlacklistedByDomain', () => {
});
});

describe('isBlockedTLD', () => {
const blockedTlds = ['.shop', '.top'];

test('should block .shop TLD', () => {
expect(isBlockedTLD('user@example.shop', blockedTlds)).toBe(true);
});

test('should block .top TLD', () => {
expect(isBlockedTLD('user@example.top', blockedTlds)).toBe(true);
});

test('should block subdomains under blocked TLDs', () => {
expect(isBlockedTLD('user@sub.domain.shop', blockedTlds)).toBe(true);
expect(isBlockedTLD('user@sub.domain.top', blockedTlds)).toBe(true);
});

test('should allow .com, .org, .io TLDs', () => {
expect(isBlockedTLD('user@example.com', blockedTlds)).toBe(false);
expect(isBlockedTLD('user@example.org', blockedTlds)).toBe(false);
expect(isBlockedTLD('user@example.io', blockedTlds)).toBe(false);
});

test('should be case insensitive', () => {
expect(isBlockedTLD('user@example.SHOP', blockedTlds)).toBe(true);
expect(isBlockedTLD('user@example.TOP', blockedTlds)).toBe(true);
expect(isBlockedTLD('USER@EXAMPLE.Shop', blockedTlds)).toBe(true);
});

test('should not block domains containing blocked TLD as a non-TLD part', () => {
expect(isBlockedTLD('user@shop.example.com', blockedTlds)).toBe(false);
expect(isBlockedTLD('user@top.example.com', blockedTlds)).toBe(false);
expect(isBlockedTLD('user@myshop.com', blockedTlds)).toBe(false);
expect(isBlockedTLD('user@topnotch.com', blockedTlds)).toBe(false);
});

test('should return false when blocklist is empty', () => {
expect(isBlockedTLD('user@example.shop', [])).toBe(false);
});

test('should handle multi-part TLDs like .co.uk', () => {
const withMultiPart = ['.shop', '.co.uk'];
expect(isBlockedTLD('user@example.co.uk', withMultiPart)).toBe(true);
expect(isBlockedTLD('user@example.com', withMultiPart)).toBe(false);
expect(isBlockedTLD('user@example.uk', withMultiPart)).toBe(false);
});
});

/**
* This test verifies the LinkedIn profile name parsing logic
* to prevent the production error: TypeError: e.default[b] is not a function
Expand Down
11 changes: 11 additions & 0 deletions src/lib/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'server-only';
import { validateAuthorizationHeader, JWT_TOKEN_VERSION } from './tokens';
import { NextResponse } from 'next/server';
import { cookies, headers } from 'next/headers';

import type { CreateOrUpdateUserArgs } from './user';
import { findUserById, createOrUpdateUser, findAndSyncExistingUser } from './user';
import { db, readDb } from '@/lib/drizzle';
Expand Down Expand Up @@ -57,6 +58,7 @@ import {
NEXTAUTH_SECRET,
GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET,
BLACKLIST_TLDS,
} from '@/lib/config.server';
import jwt from 'jsonwebtoken';
import type { UUID } from 'node:crypto';
Expand Down Expand Up @@ -436,6 +438,12 @@ const authOptions: NextAuthOptions = {

// Check if this is an existing user with a different primary email
const existingUser = await findAndSyncExistingUser(accountInfo);

// Block new signups from blocked TLDs (existing users can still sign in)
if (!existingUser && isBlockedTLD(accountInfo.google_user_email)) {
return redirectUrlForCode(`BLOCKED`, accountInfo.google_user_email);
}

if (existingUser) {
const primaryEmailDomain = getLowerDomainFromEmail(existingUser.google_user_email);
if (primaryEmailDomain) {
Expand Down Expand Up @@ -807,6 +815,9 @@ export const isEmailBlacklistedByDomain = (
email.toLowerCase().endsWith('.' + domain.toLowerCase())
);

export const isBlockedTLD = (email: string, blacklisted_tlds = BLACKLIST_TLDS) =>
blacklisted_tlds.some(tld => email.toLowerCase().endsWith(tld));

export function report_blocked_user(kiloUserId: string) {
return authError(403, 'Access denied (R1)', kiloUserId);
}
Expand Down