fix(email): add dns validation of email addresses
This commit is contained in:
parent
3cc07f5e9f
commit
17306abec6
@ -28,6 +28,7 @@ import {AuthSessionService} from '@fluxer/api/src/auth/services/AuthSessionServi
|
||||
import {AuthUtilityService} from '@fluxer/api/src/auth/services/AuthUtilityService';
|
||||
import {createMfaTicket, type UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import type {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
|
||||
@ -160,6 +161,7 @@ export class AuthService implements IAuthService {
|
||||
gatewayService: IGatewayService,
|
||||
rateLimitService: IRateLimitService,
|
||||
emailServiceDep: IEmailService,
|
||||
emailDnsValidationService: IEmailDnsValidationService,
|
||||
smsService: ISmsService,
|
||||
snowflakeService: SnowflakeService,
|
||||
snowflakeReservationService: SnowflakeReservationService,
|
||||
@ -182,6 +184,7 @@ export class AuthService implements IAuthService {
|
||||
this.passwordService = new AuthPasswordService(
|
||||
repository,
|
||||
emailServiceDep,
|
||||
emailDnsValidationService,
|
||||
rateLimitService,
|
||||
this.utilityService.generateSecureToken.bind(this.utilityService),
|
||||
this.utilityService.handleBanStatus.bind(this.utilityService),
|
||||
@ -198,6 +201,7 @@ export class AuthService implements IAuthService {
|
||||
inviteService,
|
||||
rateLimitService,
|
||||
emailServiceDep,
|
||||
emailDnsValidationService,
|
||||
snowflakeService,
|
||||
snowflakeReservationService,
|
||||
discriminatorService,
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {createPasswordResetToken} from '@fluxer/api/src/BrandedTypes';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
@ -111,6 +112,7 @@ export class AuthPasswordService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
private emailService: IEmailService,
|
||||
private emailDnsValidationService: IEmailDnsValidationService,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private generateSecureToken: () => Promise<string>,
|
||||
private handleBanStatus: (user: User) => Promise<User>,
|
||||
@ -236,6 +238,11 @@ export class AuthPasswordService {
|
||||
});
|
||||
}
|
||||
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(data.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const user = await this.repository.findByEmail(data.email);
|
||||
if (!user) {
|
||||
return;
|
||||
|
||||
@ -22,6 +22,7 @@ import {Config} from '@fluxer/api/src/Config';
|
||||
import {FIRST_ADMIN_ACL_CONFIG_KEY} from '@fluxer/api/src/constants/InstanceConfig';
|
||||
import {deleteOneOrMany, executeConditional} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {IDiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import type {KVActivityTracker} from '@fluxer/api/src/infrastructure/KVActivityTracker';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
|
||||
@ -44,6 +45,7 @@ import {deriveUsernameFromDisplayName} from '@fluxer/api/src/utils/UsernameSugge
|
||||
import type {ICacheService} from '@fluxer/cache/src/ICacheService';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
@ -168,6 +170,7 @@ export class AuthRegistrationService {
|
||||
private inviteService: InviteService | null,
|
||||
private rateLimitService: IRateLimitService,
|
||||
private emailService: IEmailService,
|
||||
private emailDnsValidationService: IEmailDnsValidationService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private snowflakeReservationService: SnowflakeReservationService,
|
||||
private discriminatorService: IDiscriminatorService,
|
||||
@ -222,6 +225,11 @@ export class AuthRegistrationService {
|
||||
await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey});
|
||||
|
||||
if (rawEmail) {
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(rawEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const emailTaken = await this.repository.findByEmail(rawEmail);
|
||||
if (emailTaken) throw InputValidationError.create('email', 'Email already in use');
|
||||
}
|
||||
|
||||
@ -19,7 +19,10 @@
|
||||
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IDonationRepository} from '@fluxer/api/src/donation/IDonationRepository';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {DonationAmountInvalidError} from '@fluxer/errors/src/domains/donation/DonationAmountInvalidError';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import {StripePaymentNotAvailableError} from '@fluxer/errors/src/domains/payment/StripePaymentNotAvailableError';
|
||||
@ -32,6 +35,7 @@ export class DonationCheckoutService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private donationRepository: IDonationRepository,
|
||||
private emailDnsValidationService: IEmailDnsValidationService,
|
||||
) {}
|
||||
|
||||
async createCheckout(params: {
|
||||
@ -48,6 +52,11 @@ export class DonationCheckoutService {
|
||||
throw new DonationAmountInvalidError();
|
||||
}
|
||||
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const isRecurring = params.interval !== null;
|
||||
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
|
||||
|
||||
|
||||
@ -21,9 +21,12 @@ import {randomBytes} from 'node:crypto';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IDonationRepository} from '@fluxer/api/src/donation/IDonationRepository';
|
||||
import {DonorMagicLinkToken} from '@fluxer/api/src/donation/models/DonorMagicLinkToken';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {DONATION_MAGIC_LINK_EXPIRY_MS} from '@fluxer/constants/src/DonationConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {DonationMagicLinkExpiredError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkExpiredError';
|
||||
import {DonationMagicLinkInvalidError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkInvalidError';
|
||||
import {DonationMagicLinkUsedError} from '@fluxer/errors/src/domains/donation/DonationMagicLinkUsedError';
|
||||
@ -32,9 +35,15 @@ export class DonationMagicLinkService {
|
||||
constructor(
|
||||
private donationRepository: IDonationRepository,
|
||||
private emailService: IEmailService,
|
||||
private emailDnsValidationService: IEmailDnsValidationService,
|
||||
) {}
|
||||
|
||||
async sendMagicLink(email: string): Promise<void> {
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const donor = await this.donationRepository.findDonorByEmail(email);
|
||||
if (!donor) {
|
||||
Logger.info({email}, 'Donation magic link requested for unknown donor');
|
||||
|
||||
196
packages/api/src/infrastructure/EmailDnsValidationService.tsx
Normal file
196
packages/api/src/infrastructure/EmailDnsValidationService.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Resolver} from 'node:dns/promises';
|
||||
import {Config} from '@fluxer/api/src/Config';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
interface DomainValidationCacheEntry {
|
||||
valid: boolean;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
interface IDnsResolver {
|
||||
resolveMx(domain: string): Promise<Array<{exchange: string; priority: number}>>;
|
||||
resolve4(domain: string): Promise<Array<string>>;
|
||||
resolve6(domain: string): Promise<Array<string>>;
|
||||
}
|
||||
|
||||
interface EmailDnsValidationServiceOptions {
|
||||
resolver?: IDnsResolver;
|
||||
enforceInTestMode?: boolean;
|
||||
positiveTtlMs?: number;
|
||||
negativeTtlMs?: number;
|
||||
}
|
||||
|
||||
type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error';
|
||||
|
||||
const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']);
|
||||
const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']);
|
||||
|
||||
export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
private readonly resolver: IDnsResolver;
|
||||
private readonly enforceInTestMode: boolean;
|
||||
private readonly positiveTtlMs: number;
|
||||
private readonly negativeTtlMs: number;
|
||||
private readonly domainCache = new Map<string, DomainValidationCacheEntry>();
|
||||
|
||||
constructor(options: EmailDnsValidationServiceOptions = {}) {
|
||||
this.resolver = options.resolver ?? new Resolver();
|
||||
this.enforceInTestMode = options.enforceInTestMode ?? false;
|
||||
this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes');
|
||||
this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes');
|
||||
}
|
||||
|
||||
async hasValidDnsRecords(email: string): Promise<boolean> {
|
||||
if (!this.enforceInTestMode && Config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const domain = this.extractDomain(email);
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = this.getCachedDomainResult(domain);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const isValid = await this.resolveDomain(domain);
|
||||
this.setCachedDomainResult(domain, isValid);
|
||||
return isValid;
|
||||
}
|
||||
|
||||
private extractDomain(email: string): string | null {
|
||||
const atIndex = email.lastIndexOf('@');
|
||||
if (atIndex <= 0 || atIndex === email.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return email.slice(atIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
private getCachedDomainResult(domain: string): boolean | null {
|
||||
const cached = this.domainCache.get(domain);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() >= cached.expiresAtMs) {
|
||||
this.domainCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
private setCachedDomainResult(domain: string, isValid: boolean): void {
|
||||
const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
this.domainCache.set(domain, {
|
||||
valid: isValid,
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDomain(domain: string): Promise<boolean> {
|
||||
const mxResult = await this.resolveMx(domain);
|
||||
if (mxResult === 'valid') {
|
||||
return true;
|
||||
}
|
||||
if (mxResult === 'invalid') {
|
||||
return false;
|
||||
}
|
||||
if (mxResult === 'transient_error') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const addressResult = await this.resolveAddressRecords(domain);
|
||||
if (addressResult === 'valid') {
|
||||
return true;
|
||||
}
|
||||
if (addressResult === 'invalid') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async resolveMx(domain: string): Promise<DnsResolutionResult> {
|
||||
try {
|
||||
const records = await this.resolver.resolveMx(domain);
|
||||
if (records.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
return 'fallback';
|
||||
} catch (error) {
|
||||
return this.classifyResolverError(error, domain, true);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveAddressRecords(domain: string): Promise<DnsResolutionResult> {
|
||||
const [ipv4Result, ipv6Result] = await Promise.allSettled([
|
||||
this.resolver.resolve4(domain),
|
||||
this.resolver.resolve6(domain),
|
||||
]);
|
||||
|
||||
if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
if (ipv6Result.status === 'fulfilled' && ipv6Result.value.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
const ipv4Classification =
|
||||
ipv4Result.status === 'rejected' ? this.classifyResolverError(ipv4Result.reason, domain, false) : 'invalid';
|
||||
const ipv6Classification =
|
||||
ipv6Result.status === 'rejected' ? this.classifyResolverError(ipv6Result.reason, domain, false) : 'invalid';
|
||||
|
||||
if (ipv4Classification === 'transient_error' || ipv6Classification === 'transient_error') {
|
||||
return 'transient_error';
|
||||
}
|
||||
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
private classifyResolverError(
|
||||
error: unknown,
|
||||
domain: string,
|
||||
allowFallbackForNoRecords: boolean,
|
||||
): DnsResolutionResult {
|
||||
const code = this.extractErrorCode(error);
|
||||
if (code && DOMAIN_NOT_FOUND_CODES.has(code)) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (code && DOMAIN_NO_RECORD_CODES.has(code)) {
|
||||
return allowFallbackForNoRecords ? 'fallback' : 'invalid';
|
||||
}
|
||||
|
||||
Logger.warn({domain, code, error}, 'Email DNS lookup failed with a transient error, allowing request');
|
||||
return 'transient_error';
|
||||
}
|
||||
|
||||
private extractErrorCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) {
|
||||
return null;
|
||||
}
|
||||
const code = (error as {code?: unknown}).code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface IEmailDnsValidationService {
|
||||
hasValidDnsRecords(email: string): Promise<boolean>;
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EmailDnsValidationService} from '@fluxer/api/src/infrastructure/EmailDnsValidationService';
|
||||
import {describe, expect, test, vi} from 'vitest';
|
||||
|
||||
type ResolveMxFn = (domain: string) => Promise<Array<{exchange: string; priority: number}>>;
|
||||
type ResolveAddressFn = (domain: string) => Promise<Array<string>>;
|
||||
|
||||
interface MockResolver {
|
||||
resolveMx: ReturnType<typeof vi.fn<ResolveMxFn>>;
|
||||
resolve4: ReturnType<typeof vi.fn<ResolveAddressFn>>;
|
||||
resolve6: ReturnType<typeof vi.fn<ResolveAddressFn>>;
|
||||
}
|
||||
|
||||
function createDnsError(code: string): Error & {code: string} {
|
||||
const error = new Error(`DNS lookup failed: ${code}`) as Error & {code: string};
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function createMockResolver(): MockResolver {
|
||||
return {
|
||||
resolveMx: vi.fn<ResolveMxFn>(),
|
||||
resolve4: vi.fn<ResolveAddressFn>(),
|
||||
resolve6: vi.fn<ResolveAddressFn>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EmailDnsValidationService', () => {
|
||||
test('accepts email domains that have MX records', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockResolvedValue([{exchange: 'mx.example.com', priority: 10}]);
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const result = await service.hasValidDnsRecords('person@example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(resolver.resolveMx).toHaveBeenCalledWith('example.com');
|
||||
expect(resolver.resolve4).not.toHaveBeenCalled();
|
||||
expect(resolver.resolve6).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('falls back to A/AAAA when MX is missing', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockRejectedValue(createDnsError('ENODATA'));
|
||||
resolver.resolve4.mockResolvedValue(['192.0.2.10']);
|
||||
resolver.resolve6.mockResolvedValue([]);
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const result = await service.hasValidDnsRecords('person@example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(resolver.resolveMx).toHaveBeenCalledWith('example.com');
|
||||
expect(resolver.resolve4).toHaveBeenCalledWith('example.com');
|
||||
expect(resolver.resolve6).toHaveBeenCalledWith('example.com');
|
||||
});
|
||||
|
||||
test('rejects domains that have no MX and no A/AAAA records', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockRejectedValue(createDnsError('ENODATA'));
|
||||
resolver.resolve4.mockRejectedValue(createDnsError('ENODATA'));
|
||||
resolver.resolve6.mockRejectedValue(createDnsError('ENODATA'));
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const result = await service.hasValidDnsRecords('person@example.com');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects domains that do not exist', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockRejectedValue(createDnsError('ENOTFOUND'));
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const result = await service.hasValidDnsRecords('person@missing-domain.invalid');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(resolver.resolve4).not.toHaveBeenCalled();
|
||||
expect(resolver.resolve6).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fails open on transient resolver errors', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockRejectedValue(createDnsError('ETIMEOUT'));
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const result = await service.hasValidDnsRecords('person@example.com');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('caches results by normalised domain', async () => {
|
||||
const resolver = createMockResolver();
|
||||
resolver.resolveMx.mockResolvedValue([{exchange: 'mx.example.com', priority: 10}]);
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
});
|
||||
|
||||
const first = await service.hasValidDnsRecords('person@Example.com');
|
||||
const second = await service.hasValidDnsRecords('someone@example.com');
|
||||
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(true);
|
||||
expect(resolver.resolveMx).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -72,6 +72,7 @@ import {
|
||||
import {DisabledLiveKitService} from '@fluxer/api/src/infrastructure/DisabledLiveKitService';
|
||||
import {DisabledVirusScanService} from '@fluxer/api/src/infrastructure/DisabledVirusScanService';
|
||||
import {DiscriminatorService} from '@fluxer/api/src/infrastructure/DiscriminatorService';
|
||||
import {EmailDnsValidationService} from '@fluxer/api/src/infrastructure/EmailDnsValidationService';
|
||||
import {EmbedService} from '@fluxer/api/src/infrastructure/EmbedService';
|
||||
import {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
|
||||
import {ErrorI18nService} from '@fluxer/api/src/infrastructure/ErrorI18nService';
|
||||
@ -201,6 +202,14 @@ function getRateLimitService(): RateLimitService {
|
||||
return _rateLimitService;
|
||||
}
|
||||
|
||||
let _emailDnsValidationService: EmailDnsValidationService | null = null;
|
||||
function getEmailDnsValidationService(): EmailDnsValidationService {
|
||||
if (!_emailDnsValidationService) {
|
||||
_emailDnsValidationService = new EmailDnsValidationService();
|
||||
}
|
||||
return _emailDnsValidationService;
|
||||
}
|
||||
|
||||
function createRuntimeSmsProvider(): ISmsProvider {
|
||||
if (Config.dev.testModeEnabled) {
|
||||
return createSmsProvider({
|
||||
@ -351,6 +360,7 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
const cacheService = getCacheService();
|
||||
const kvClient = getKVClient();
|
||||
const rateLimitService = getRateLimitService();
|
||||
const emailDnsValidationService = getEmailDnsValidationService();
|
||||
const purgeQueue = getPurgeQueue();
|
||||
const assetDeletionQueue = getAssetDeletionQueue();
|
||||
const csamLegalHoldService = getCsamLegalHoldService();
|
||||
@ -592,6 +602,7 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
gatewayService,
|
||||
rateLimitService,
|
||||
emailService,
|
||||
emailDnsValidationService,
|
||||
smsService,
|
||||
snowflakeService,
|
||||
snowflakeReservationService,
|
||||
@ -622,6 +633,7 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
userRepository,
|
||||
inviteRepository,
|
||||
emailService,
|
||||
emailDnsValidationService,
|
||||
snowflakeService,
|
||||
storageService,
|
||||
reportSearchService,
|
||||
@ -717,6 +729,7 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
emailService,
|
||||
userRepository,
|
||||
rateLimitService,
|
||||
emailDnsValidationService,
|
||||
);
|
||||
|
||||
const passwordChangeRepository = new PasswordChangeRepository();
|
||||
@ -788,8 +801,16 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
donationRepository,
|
||||
);
|
||||
|
||||
const donationMagicLinkService = new DonationMagicLinkService(donationRepository, emailService);
|
||||
const donationCheckoutService = new DonationCheckoutService(stripeService.getStripe(), donationRepository);
|
||||
const donationMagicLinkService = new DonationMagicLinkService(
|
||||
donationRepository,
|
||||
emailService,
|
||||
emailDnsValidationService,
|
||||
);
|
||||
const donationCheckoutService = new DonationCheckoutService(
|
||||
stripeService.getStripe(),
|
||||
donationRepository,
|
||||
emailDnsValidationService,
|
||||
);
|
||||
donationService = new DonationService(donationMagicLinkService, donationCheckoutService);
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ import * as MessageHelpers from '@fluxer/api/src/channel/services/message/Messag
|
||||
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
|
||||
import type {DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
|
||||
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
|
||||
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
|
||||
@ -50,10 +51,12 @@ import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchSer
|
||||
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
|
||||
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {IEmailService} from '@fluxer/email/src/IEmailService';
|
||||
import {CannotReportOwnMessageError} from '@fluxer/errors/src/domains/channel/CannotReportOwnMessageError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
||||
import {CannotReportOwnGuildError} from '@fluxer/errors/src/domains/guild/CannotReportOwnGuildError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
@ -97,6 +100,7 @@ export class ReportService {
|
||||
private userRepository: IUserRepository,
|
||||
private inviteRepository: IInviteRepository,
|
||||
private emailService: IEmailService,
|
||||
private emailDnsValidationService: IEmailDnsValidationService,
|
||||
private snowflakeService: SnowflakeService,
|
||||
private storageService: IStorageService,
|
||||
private reportSearchService: IReportSearchService | null = null,
|
||||
@ -327,6 +331,11 @@ export class ReportService {
|
||||
|
||||
async sendDsaReportVerificationCode(email: string): Promise<void> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
|
||||
const verificationCode = this.generateDsaVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + ms('10 minutes'));
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '@fluxer/api/src/auth/services/AuthEmailService';
|
||||
import type {IEmailDnsValidationService} from '@fluxer/api/src/infrastructure/IEmailDnsValidationService';
|
||||
import type {User} from '@fluxer/api/src/models/User';
|
||||
import type {EmailChangeRepository} from '@fluxer/api/src/user/repositories/auth/EmailChangeRepository';
|
||||
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
|
||||
@ -61,6 +62,7 @@ export class EmailChangeService {
|
||||
private readonly emailService: IEmailService,
|
||||
private readonly userAccountRepository: IUserAccountRepository,
|
||||
private readonly rateLimitService: IRateLimitService,
|
||||
private readonly emailDnsValidationService: IEmailDnsValidationService,
|
||||
) {}
|
||||
|
||||
async start(user: User): Promise<StartEmailChangeResult> {
|
||||
@ -191,6 +193,10 @@ export class EmailChangeService {
|
||||
if (row.original_email && trimmedEmail.toLowerCase() === row.original_email.toLowerCase()) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.NEW_EMAIL_MUST_BE_DIFFERENT);
|
||||
}
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(trimmedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
}
|
||||
const existing = await this.userAccountRepository.findByEmail(trimmedEmail.toLowerCase());
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user