Advanced registration validation: email domain check, username blacklist, MX verification

This commit is contained in:
Georgiy Syralev
2026-01-01 17:10:13 +03:00
parent bdb333958a
commit fc194dd582
4 changed files with 485 additions and 27 deletions

View File

@@ -6,6 +6,19 @@
// Regex for basic email format validation
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
// Valid TLDs (top-level domains)
const VALID_TLDS = new Set([
'com', 'org', 'net', 'edu', 'gov', 'mil', 'int',
'ru', 'рф', 'su', 'by', 'ua', 'kz', 'uz', 'kg', 'tj', 'am', 'az', 'ge', 'md',
'uk', 'de', 'fr', 'it', 'es', 'pl', 'nl', 'be', 'ch', 'at', 'se', 'no', 'dk', 'fi',
'cn', 'jp', 'kr', 'in', 'au', 'nz', 'ca', 'mx', 'br', 'ar',
'io', 'co', 'me', 'tv', 'info', 'biz', 'name', 'pro', 'mobi', 'tel', 'asia',
]);
// Minimum lengths for domain parts
const MIN_DOMAIN_LENGTH = 2;
const MIN_TLD_LENGTH = 2;
// Common disposable email domains (partial list)
const DISPOSABLE_DOMAINS = new Set([
'10minutemail.com',
@@ -59,6 +72,39 @@ export function validateEmailFormat(email: string): boolean {
return EMAIL_REGEX.test(email.trim().toLowerCase());
}
/**
* Validate email domain (prevent a@a.a type emails)
*/
export function validateEmailDomain(email: string): { isValid: boolean; error?: string } {
const parts = email.split('@');
if (parts.length !== 2) {
return { isValid: false, error: 'Invalid email format' };
}
const domain = parts[1].toLowerCase();
const domainParts = domain.split('.');
// Must have at least 2 parts (domain.tld)
if (domainParts.length < 2) {
return { isValid: false, error: 'Invalid domain' };
}
// Check each part length
for (const part of domainParts) {
if (part.length < MIN_DOMAIN_LENGTH) {
return { isValid: false, error: 'Domain parts too short' };
}
}
// Check TLD
const tld = domainParts[domainParts.length - 1];
if (tld.length < MIN_TLD_LENGTH || !VALID_TLDS.has(tld)) {
return { isValid: false, error: 'Invalid or unsupported domain' };
}
return { isValid: true };
}
/**
* Check if email domain is disposable
*/
@@ -106,6 +152,17 @@ export function validateEmail(email: string, locale: 'ru' | 'en' = 'ru'): EmailV
};
}
// Check domain validity
const domainCheck = validateEmailDomain(trimmedEmail);
if (!domainCheck.isValid) {
return {
isValid: false,
error: locale === 'en'
? 'Invalid email domain. Please use a valid email address.'
: 'Недействительный домен email. Используйте настоящий email.',
};
}
// Check for disposable emails
if (isDisposableEmail(trimmedEmail)) {
return {

View File

@@ -0,0 +1,174 @@
/**
* External email verification API service
* Uses multiple free APIs for email validation
*/
export interface EmailAPIResult {
valid: boolean;
disposable?: boolean;
freeProvider?: boolean;
error?: string;
}
/**
* Verify email using Hunter.io email verifier API (free tier available)
* Alternative: emailvalidation.io, verify-email.org
* Currently unused but kept for future implementation
*/
export async function verifyWithHunter(email: string): Promise<EmailAPIResult> {
try {
// Using hunter.io free API endpoint
const response = await fetch(
`https://api.hunter.io/v2/email-verifier?email=${encodeURIComponent(email)}&api_key=demo`,
{
method: 'GET',
headers: { 'Accept': 'application/json' }
}
);
if (!response.ok) {
throw new Error('API request failed');
}
const data = await response.json();
if (data.data) {
return {
valid: data.data.status === 'valid' || data.data.result === 'deliverable',
disposable: data.data.disposable || false,
freeProvider: data.data.webmail || false,
};
}
return { valid: true }; // If API doesn't work, don't block
} catch (error) {
console.warn('Hunter API verification failed:', error);
return { valid: true }; // Fallback to allow registration
}
}
/**
* Verify email using abstract email validation API
*/
async function verifyWithAbstract(email: string): Promise<EmailAPIResult> {
try {
const apiKey = import.meta.env.VITE_ABSTRACT_EMAIL_API_KEY;
if (!apiKey) {
return { valid: true }; // Skip if no API key
}
const response = await fetch(
`https://emailvalidation.abstractapi.com/v1/?api_key=${apiKey}&email=${encodeURIComponent(email)}`
);
if (!response.ok) {
throw new Error('API request failed');
}
const data = await response.json();
return {
valid: data.deliverability !== 'UNDELIVERABLE' && data.is_valid_format?.value === true,
disposable: data.is_disposable_email?.value || false,
freeProvider: data.is_free_email?.value || false,
};
} catch (error) {
console.warn('Abstract API verification failed:', error);
return { valid: true };
}
}
/**
* Simple DNS MX record check simulation
* In production, this should be done on the backend
*/
async function checkMXRecords(domain: string): Promise<boolean> {
// This is a client-side limitation - MX record checks should be done on backend
// For now, we'll use a simple domain existence check via DNS-over-HTTPS
try {
const response = await fetch(
`https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=MX`,
{ method: 'GET' }
);
if (!response.ok) {
return true; // If check fails, don't block
}
const data = await response.json();
// Check if MX records exist
return data.Answer && data.Answer.length > 0;
} catch (error) {
console.warn('MX record check failed:', error);
return true; // Fallback to allow
}
}
/**
* Main email verification function with multiple methods
*/
export async function verifyEmailWithAPI(
email: string,
options: { useAPI?: boolean; checkMX?: boolean } = {}
): Promise<EmailAPIResult> {
const { useAPI = false, checkMX = true } = options;
const domain = email.split('@')[1];
if (!domain) {
return { valid: false, error: 'Invalid email format' };
}
// Check MX records first (lightweight)
if (checkMX) {
const hasMX = await checkMXRecords(domain);
if (!hasMX) {
return {
valid: false,
error: 'Email domain has no mail server',
};
}
}
// Use external API if enabled
if (useAPI) {
// Try Abstract API first (if key is available)
const abstractResult = await verifyWithAbstract(email);
if (!abstractResult.valid) {
return abstractResult;
}
// Fallback to Hunter.io (free demo)
// const hunterResult = await verifyWithHunter(email);
// return hunterResult;
}
return { valid: true };
}
/**
* Quick check if email might be valid (client-side only)
*/
export function quickEmailCheck(email: string): { valid: boolean; reason?: string } {
// Check for common invalid patterns
const invalidPatterns = [
/^[a-z]@[a-z]\.[a-z]$/i, // a@a.a pattern
/^test@test\./i, // test@test.*
/^admin@admin\./i, // admin@admin.*
/^\d+@\d+\./, // numbers only
/@localhost$/i, // localhost domain
/@example\./i, // example domain
/@test\./i, // test domain
];
for (const pattern of invalidPatterns) {
if (pattern.test(email)) {
return {
valid: false,
reason: 'This email address appears to be invalid or for testing purposes',
};
}
}
return { valid: true };
}

View File

@@ -0,0 +1,156 @@
/**
* Username blacklist validation
* Prevents registration with profanity and reserved words
*/
// Profanity and inappropriate words (Russian)
const RUSSIAN_BLACKLIST = [
'хуй', 'хуя', 'хуи', 'хуе', 'хую', 'xyй', 'xуй', 'хyй', 'xyи', 'xуи', 'хyи',
'пизд', 'пизда', 'пиздец', 'пизды', 'пизде', 'пиздой', 'piзд', 'пiзд',
'ебал', 'ебать', 'ебет', 'ебут', 'ебал', 'ебала', 'ебало', 'еба',
'бля', 'блять', 'блядь', 'бляд', 'блять', 'блят', 'блядина', 'блядский',
'сука', 'суки', 'суку', 'сукой', 'cyка', 'cука', 'са',
'гандон', 'гондон', 'пидор', 'пидар', 'педик', 'пидр', 'пидарас',
'уебок', 'уебан', 'дебил', 'мудак', 'долбоеб', 'еблан',
'залупа', 'жопа', 'срака', 'говно', 'дерьмо', 'срать',
'манда', 'мандавошка', 'влагалище',
];
// Profanity and inappropriate words (English)
const ENGLISH_BLACKLIST = [
'fuck', 'fucked', 'fucker', 'fucking', 'fucks', 'fuk', 'fck', 'f**k',
'shit', 'shits', 'shitty', 'shitter', 'sh1t', 'sht',
'bitch', 'bitches', 'bitching', 'b1tch', 'btch',
'ass', 'asshole', 'arsehole', 'arse', 'a55', 'a55hole',
'dick', 'dicks', 'dickhead', 'd1ck', 'dik',
'cock', 'cocks', 'c0ck', 'cok',
'cunt', 'cunts', 'c**t', 'cnt',
'pussy', 'pussies', 'puss',
'whore', 'slut', 'sluts', 'slutty',
'bastard', 'bastards', 'wanker', 'tosser',
'nigger', 'nigga', 'negro', 'n1gger', 'n1gga',
'faggot', 'fag', 'fags', 'fagot', 'f4ggot',
'retard', 'retarded', 'tard',
'rape', 'raping', 'rapist',
'nazi', 'hitler', 'heil',
];
// Reserved system usernames
const RESERVED_NAMES = [
'admin', 'administrator', 'root', 'system', 'support',
'moderator', 'mod', 'operator', 'staff', 'team',
'official', 'help', 'info', 'contact', 'service',
'user', 'username', 'test', 'demo', 'guest',
'null', 'undefined', 'unknown', 'anonymous', 'anon',
'ospab', 'ospabhost', 'hosting', 'server',
];
// Common number patterns to avoid (like test123, user1, etc)
const GENERIC_PATTERNS = [
/^user\d+$/i,
/^test\d*$/i,
/^admin\d+$/i,
/^\d+$/, // Only numbers
];
// Combine all blacklists
const COMBINED_BLACKLIST = new Set([
...RUSSIAN_BLACKLIST,
...ENGLISH_BLACKLIST,
...RESERVED_NAMES,
]);
export type UsernameValidationResult = {
isValid: boolean;
error?: string;
};
/**
* Check if username contains blacklisted words
*/
function containsBlacklistedWord(username: string): boolean {
const lower = username.toLowerCase();
// Check exact matches
if (COMBINED_BLACKLIST.has(lower)) {
return true;
}
// Check if username contains any blacklisted word
for (const word of COMBINED_BLACKLIST) {
if (lower.includes(word)) {
return true;
}
}
// Check generic patterns
for (const pattern of GENERIC_PATTERNS) {
if (pattern.test(username)) {
return true;
}
}
return false;
}
/**
* Validate username
*/
export function validateUsername(
username: string,
locale: 'ru' | 'en' = 'ru'
): UsernameValidationResult {
const trimmed = username.trim();
// Check length
if (trimmed.length < 3) {
return {
isValid: false,
error: locale === 'en'
? 'Username must be at least 3 characters long'
: 'Имя пользователя должно быть не менее 3 символов',
};
}
if (trimmed.length > 20) {
return {
isValid: false,
error: locale === 'en'
? 'Username must be no more than 20 characters'
: 'Имя пользователя должно быть не более 20 символов',
};
}
// Check format (alphanumeric, underscore, hyphen)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
return {
isValid: false,
error: locale === 'en'
? 'Username can only contain letters, numbers, underscore and hyphen'
: 'Имя пользователя может содержать только буквы, цифры, подчёркивание и дефис',
};
}
// Check blacklist
if (containsBlacklistedWord(trimmed)) {
return {
isValid: false,
error: locale === 'en'
? 'This username is not allowed. Please choose another one.'
: 'Это имя пользователя недопустимо. Пожалуйста, выберите другое.',
};
}
return { isValid: true };
}
/**
* Get username validation error message
*/
export function getUsernameError(
username: string,
locale: 'ru' | 'en' = 'ru'
): string | null {
const result = validateUsername(username, locale);
return result.error || null;
}