diff --git a/packages/api/src/auth/AuthService.tsx b/packages/api/src/auth/AuthService.tsx index e5bcbe09..858c415d 100644 --- a/packages/api/src/auth/AuthService.tsx +++ b/packages/api/src/auth/AuthService.tsx @@ -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, diff --git a/packages/api/src/auth/services/AuthPasswordService.tsx b/packages/api/src/auth/services/AuthPasswordService.tsx index bcc362da..dbe4f806 100644 --- a/packages/api/src/auth/services/AuthPasswordService.tsx +++ b/packages/api/src/auth/services/AuthPasswordService.tsx @@ -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, private handleBanStatus: (user: User) => Promise, @@ -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; diff --git a/packages/api/src/auth/services/AuthRegistrationService.tsx b/packages/api/src/auth/services/AuthRegistrationService.tsx index 390406dc..5e9447d8 100644 --- a/packages/api/src/auth/services/AuthRegistrationService.tsx +++ b/packages/api/src/auth/services/AuthRegistrationService.tsx @@ -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'); } diff --git a/packages/api/src/donation/services/DonationCheckoutService.tsx b/packages/api/src/donation/services/DonationCheckoutService.tsx index 61c3e64d..d43b40ea 100644 --- a/packages/api/src/donation/services/DonationCheckoutService.tsx +++ b/packages/api/src/donation/services/DonationCheckoutService.tsx @@ -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); diff --git a/packages/api/src/donation/services/DonationMagicLinkService.tsx b/packages/api/src/donation/services/DonationMagicLinkService.tsx index 3e1629c1..21ffb7cc 100644 --- a/packages/api/src/donation/services/DonationMagicLinkService.tsx +++ b/packages/api/src/donation/services/DonationMagicLinkService.tsx @@ -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 { + 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'); diff --git a/packages/api/src/infrastructure/EmailDnsValidationService.tsx b/packages/api/src/infrastructure/EmailDnsValidationService.tsx new file mode 100644 index 00000000..dff7c038 --- /dev/null +++ b/packages/api/src/infrastructure/EmailDnsValidationService.tsx @@ -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 . + */ + +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>; + resolve4(domain: string): Promise>; + resolve6(domain: string): Promise>; +} + +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(); + + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/api/src/infrastructure/IEmailDnsValidationService.tsx b/packages/api/src/infrastructure/IEmailDnsValidationService.tsx new file mode 100644 index 00000000..45c9e9c5 --- /dev/null +++ b/packages/api/src/infrastructure/IEmailDnsValidationService.tsx @@ -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 . + */ + +export interface IEmailDnsValidationService { + hasValidDnsRecords(email: string): Promise; +} diff --git a/packages/api/src/infrastructure/tests/EmailDnsValidationService.test.tsx b/packages/api/src/infrastructure/tests/EmailDnsValidationService.test.tsx new file mode 100644 index 00000000..e79dc757 --- /dev/null +++ b/packages/api/src/infrastructure/tests/EmailDnsValidationService.test.tsx @@ -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 . + */ + +import {EmailDnsValidationService} from '@fluxer/api/src/infrastructure/EmailDnsValidationService'; +import {describe, expect, test, vi} from 'vitest'; + +type ResolveMxFn = (domain: string) => Promise>; +type ResolveAddressFn = (domain: string) => Promise>; + +interface MockResolver { + resolveMx: ReturnType>; + resolve4: ReturnType>; + resolve6: ReturnType>; +} + +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(), + resolve4: vi.fn(), + resolve6: vi.fn(), + }; +} + +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); + }); +}); diff --git a/packages/api/src/middleware/ServiceMiddleware.tsx b/packages/api/src/middleware/ServiceMiddleware.tsx index b7f6b6ab..2bef86b1 100644 --- a/packages/api/src/middleware/ServiceMiddleware.tsx +++ b/packages/api/src/middleware/ServiceMiddleware.tsx @@ -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(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(async (ctx, next) => gatewayService, rateLimitService, emailService, + emailDnsValidationService, smsService, snowflakeService, snowflakeReservationService, @@ -622,6 +633,7 @@ export const ServiceMiddleware = createMiddleware(async (ctx, next) => userRepository, inviteRepository, emailService, + emailDnsValidationService, snowflakeService, storageService, reportSearchService, @@ -717,6 +729,7 @@ export const ServiceMiddleware = createMiddleware(async (ctx, next) => emailService, userRepository, rateLimitService, + emailDnsValidationService, ); const passwordChangeRepository = new PasswordChangeRepository(); @@ -788,8 +801,16 @@ export const ServiceMiddleware = createMiddleware(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); } diff --git a/packages/api/src/report/ReportService.tsx b/packages/api/src/report/ReportService.tsx index 57370914..a3df1487 100644 --- a/packages/api/src/report/ReportService.tsx +++ b/packages/api/src/report/ReportService.tsx @@ -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 { 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')); diff --git a/packages/api/src/user/services/EmailChangeService.tsx b/packages/api/src/user/services/EmailChangeService.tsx index 151cb724..8970f870 100644 --- a/packages/api/src/user/services/EmailChangeService.tsx +++ b/packages/api/src/user/services/EmailChangeService.tsx @@ -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 { @@ -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);