fix(api): reject a mail-less email domain by its own code (#2608)

This commit is contained in:
Hampus
2026-09-08 22:17:53 +02:00
committed by Hampus Kraft
parent 8cc485cf81
commit c577b97f35
12 changed files with 302 additions and 23 deletions
+1 -1
View File
@@ -209,7 +209,7 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
}
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const user = await users.findByEmail(data.email);
if (!user) {
+1 -1
View File
@@ -187,7 +187,7 @@ export async function register(
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
if (contactDomainBlocked) {
@@ -40,7 +40,7 @@ export class DonationCheckoutService {
}
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const isRecurring = params.interval !== null;
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
@@ -24,7 +24,7 @@ export class DonationMagicLinkService {
async sendMagicLink(email: string): Promise<void> {
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const donor = await this.donationRepository.findDonorByEmail(email);
if (!donor) {
@@ -27,25 +27,51 @@ interface EmailDnsValidationServiceOptions {
enforceInTestMode?: boolean;
positiveTtlMs?: number;
negativeTtlMs?: number;
lookupTimeoutMs?: number;
maxCachedDomains?: number;
isEmailEnabled?: () => Promise<boolean>;
}
type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error';
type DomainVerdict = 'valid' | 'invalid' | 'unverified';
const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']);
const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']);
const DNS_LOOKUP_TIMEOUT_MS = 2000;
const DNS_LOOKUP_TRIES = 1;
const MAX_CACHED_DOMAINS = 10000;
async function isInstanceEmailEnabled(): Promise<boolean> {
const {getInstanceConfigRepository} = await import('../middleware/ServiceSingletons');
return getInstanceConfigRepository().isEmailEnabled();
}
function createLookupTimeoutError(): NodeJS.ErrnoException {
const error: NodeJS.ErrnoException = new Error('Email DNS lookup timed out');
error.code = 'ETIMEOUT';
return error;
}
export class EmailDnsValidationService implements IEmailDnsValidationService {
private readonly resolver: IDnsResolver;
private readonly enforceInTestMode: boolean;
private readonly positiveTtlMs: number;
private readonly negativeTtlMs: number;
private readonly lookupTimeoutMs: number;
private readonly maxCachedDomains: number;
private readonly isEmailEnabled: () => Promise<boolean>;
private readonly domainCache = new Map<string, DomainValidationCacheEntry>();
constructor(options: EmailDnsValidationServiceOptions = {}) {
this.resolver = options.resolver ?? new Resolver();
this.lookupTimeoutMs = options.lookupTimeoutMs ?? DNS_LOOKUP_TIMEOUT_MS;
this.resolver =
options.resolver ??
new Resolver({timeout: this.lookupTimeoutMs, tries: DNS_LOOKUP_TRIES, maxTimeout: this.lookupTimeoutMs});
this.enforceInTestMode = options.enforceInTestMode ?? false;
this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes');
this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes');
this.maxCachedDomains = options.maxCachedDomains ?? MAX_CACHED_DOMAINS;
this.isEmailEnabled = options.isEmailEnabled ?? isInstanceEmailEnabled;
}
async hasValidDnsRecords(email: string): Promise<boolean> {
@@ -56,13 +82,19 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
if (!domain) {
return false;
}
if (!(await this.isEmailEnabled())) {
return true;
}
const cached = this.getCachedDomainResult(domain);
if (cached !== null) {
return cached;
}
const isValid = await this.resolveDomain(domain);
this.setCachedDomainResult(domain, isValid);
return isValid;
const verdict = await this.resolveDomain(domain);
if (verdict === 'invalid') {
Logger.warn({domain}, 'Email domain publishes no mail exchange or address records, rejecting the address');
}
this.setCachedDomainResult(domain, verdict);
return verdict !== 'invalid';
}
private extractDomain(email: string): string | null {
@@ -82,41 +114,49 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
this.domainCache.delete(domain);
return null;
}
this.domainCache.delete(domain);
this.domainCache.set(domain, cached);
return cached.valid;
}
private setCachedDomainResult(domain: string, isValid: boolean): void {
const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs;
private setCachedDomainResult(domain: string, verdict: DomainVerdict): void {
const ttlMs = verdict === 'valid' ? this.positiveTtlMs : this.negativeTtlMs;
if (this.domainCache.size >= this.maxCachedDomains && !this.domainCache.has(domain)) {
const oldestDomain = this.domainCache.keys().next().value;
if (oldestDomain !== undefined) {
this.domainCache.delete(oldestDomain);
}
}
this.domainCache.set(domain, {
valid: isValid,
valid: verdict !== 'invalid',
expiresAtMs: Date.now() + ttlMs,
});
}
private async resolveDomain(domain: string): Promise<boolean> {
private async resolveDomain(domain: string): Promise<DomainVerdict> {
const mxResult = await this.resolveMx(domain);
if (mxResult === 'valid') {
return true;
return 'valid';
}
if (mxResult === 'invalid') {
return false;
return 'invalid';
}
if (mxResult === 'transient_error') {
return true;
return 'unverified';
}
const addressResult = await this.resolveAddressRecords(domain);
if (addressResult === 'valid') {
return true;
return 'valid';
}
if (addressResult === 'invalid') {
return false;
return 'invalid';
}
return true;
return 'unverified';
}
private async resolveMx(domain: string): Promise<DnsResolutionResult> {
try {
const records = await this.resolver.resolveMx(domain);
const records = await this.withLookupDeadline(this.resolver.resolveMx(domain));
if (records.length > 0) {
return 'valid';
}
@@ -128,8 +168,8 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
private async resolveAddressRecords(domain: string): Promise<DnsResolutionResult> {
const [ipv4Result, ipv6Result] = await Promise.allSettled([
this.resolver.resolve4(domain),
this.resolver.resolve6(domain),
this.withLookupDeadline(this.resolver.resolve4(domain)),
this.withLookupDeadline(this.resolver.resolve6(domain)),
]);
if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) {
return 'valid';
@@ -147,6 +187,20 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
return 'invalid';
}
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
lookup,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(createLookupTimeoutError()), this.lookupTimeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
private classifyResolverError(
error: unknown,
domain: string,
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ms} from 'itty-time';
import {describe, expect, it} from 'vitest';
import {Config} from '../../Config';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import {EmailDnsValidationService} from '../EmailDnsValidationService';
interface MxRecord {
exchange: string;
priority: number;
}
function dnsError(code: string): NodeJS.ErrnoException {
const error: NodeJS.ErrnoException = new Error(`dns lookup failed with ${code}`);
error.code = code;
return error;
}
class FakeDnsResolver {
readonly lookups: Array<string> = [];
mxRecords: Array<MxRecord> = [{exchange: 'mx.example.com', priority: 10}];
mxErrorCode: string | null = null;
addressErrorCode: string | null = 'ENOTFOUND';
addresses: Array<string> = [];
stall = false;
async resolveMx(domain: string): Promise<Array<MxRecord>> {
this.lookups.push(`mx:${domain}`);
if (this.stall) {
return new Promise<Array<MxRecord>>(() => {});
}
if (this.mxErrorCode) {
throw dnsError(this.mxErrorCode);
}
return this.mxRecords;
}
async resolve4(domain: string): Promise<Array<string>> {
this.lookups.push(`a:${domain}`);
if (this.addressErrorCode) {
throw dnsError(this.addressErrorCode);
}
return this.addresses;
}
async resolve6(domain: string): Promise<Array<string>> {
this.lookups.push(`aaaa:${domain}`);
if (this.addressErrorCode) {
throw dnsError(this.addressErrorCode);
}
return this.addresses;
}
}
describe('EmailDnsValidationService', () => {
it('skips the lookup when the instance sends no mail', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENOTFOUND';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => false,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual([]);
});
it('looks the domain up when the instance sends mail', async () => {
const resolver = new FakeDnsResolver();
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual(['mx:gmail.com']);
});
it('follows the env email flag when no gate is supplied and the operator set nothing', async () => {
expect(Config.email.enabled).toBe(true);
const resolver = new FakeDnsResolver();
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual(['mx:gmail.com']);
});
it('follows the runtime instance email setting over the env flag', async () => {
expect(Config.email.enabled).toBe(true);
await getInstanceConfigRepository().setInstanceIntegrationsConfig({email: {enabled: false}});
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENOTFOUND';
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual([]);
});
it('still rejects a syntactically broken address when the instance sends no mail', async () => {
const resolver = new FakeDnsResolver();
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => false,
});
expect(await service.hasValidDnsRecords('probe@')).toBe(false);
expect(resolver.lookups).toEqual([]);
});
it('rejects a domain that does not exist', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENOTFOUND';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(resolver.lookups).toEqual(['mx:asdf.asdf']);
});
it('accepts a domain that publishes address records but no mail records', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENODATA';
resolver.addressErrorCode = null;
resolver.addresses = ['198.51.100.10'];
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual(['mx:mail-less.test', 'a:mail-less.test', 'aaaa:mail-less.test']);
});
it('rejects a domain that publishes neither mail nor address records', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENODATA';
resolver.addressErrorCode = 'ENODATA';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(resolver.lookups).toEqual(['mx:no-records.test', 'a:no-records.test', 'aaaa:no-records.test']);
});
it('allows the address when the resolver fails transiently', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ESERVFAIL';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
});
it('does not cache a transient failure as a verified domain', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ESERVFAIL';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
positiveTtlMs: ms('30 minutes'),
negativeTtlMs: 0,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual(['mx:asdf.asdf', 'mx:asdf.asdf']);
});
it('caches a verified domain for the positive ttl', async () => {
const resolver = new FakeDnsResolver();
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
positiveTtlMs: ms('30 minutes'),
negativeTtlMs: 0,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(resolver.lookups).toEqual(['mx:gmail.com']);
});
it('gives up on a stalled resolver instead of hanging', async () => {
const resolver = new FakeDnsResolver();
resolver.stall = true;
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
lookupTimeoutMs: 10,
});
const startedAtMs = Date.now();
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
expect(Date.now() - startedAtMs).toBeLessThan(ms('5 seconds'));
expect(resolver.lookups).toEqual(['mx:stalled.test']);
});
it('bounds the domain cache', async () => {
const resolver = new FakeDnsResolver();
resolver.mxErrorCode = 'ENOTFOUND';
const service = new EmailDnsValidationService({
resolver,
enforceInTestMode: true,
isEmailEnabled: async () => true,
maxCachedDomains: 2,
});
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test']);
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test', 'mx:first.test']);
});
});
+1 -1
View File
@@ -343,7 +343,7 @@ export class ReportService {
const normalizedEmail = this.normalizeEmail(email);
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const verificationCode = this.generateDsaVerificationCode();
const expiresAt = new Date(Date.now() + ms('10 minutes'));
@@ -178,7 +178,7 @@ export class EmailChangeService {
}
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(trimmedEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
}
const existing = await users.findByEmail(trimmedEmail.toLowerCase());
if (existing && existing.id !== user.id) {