Add MX record validation to e-mail subscriptions, refactor validator (#38502)

This commit is contained in:
Eugen Rochko 2026-04-01 12:04:59 +02:00 committed by GitHub
parent eda0f62f89
commit 0742a01571
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 96 deletions

View File

@ -20,6 +20,7 @@ class EmailSubscription < ApplicationRecord
normalizes :email, with: ->(str) { str.squish.downcase }
validates :email, presence: true, email_address: true, uniqueness: { scope: :account_id }
validates :email, email_mx: true, if: -> { email_changed? && !Rails.env.local? }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :unconfirmed, -> { where(confirmed_at: nil) }

View File

@ -93,9 +93,9 @@ class User < ApplicationRecord
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
validates :email, presence: true, email_address: true
validates :email, email_mx: { attempt_ip: :sign_up_ip }, if: :validate_email_dns?
validates_with UserEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
validates_with EmailMxValidator, if: :validate_email_dns?
validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
# Honeypot/anti-spam fields

View File

@ -2,21 +2,21 @@
require 'resolv'
class EmailMxValidator < ActiveModel::Validator
def validate(user)
return if user.email.blank?
class EmailMxValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
domain = get_domain(user.email)
domain = get_domain(value)
if domain.blank? || domain.include?('..')
user.errors.add(:email, :invalid)
record.errors.add(attribute, :invalid)
elsif !on_allowlist?(domain)
resolved_ips, resolved_domains = resolve_mx(domain)
if resolved_ips.empty?
user.errors.add(:email, :unreachable)
elsif email_domain_blocked?(resolved_domains, user.sign_up_ip)
user.errors.add(:email, :blocked)
record.errors.add(attribute, :unreachable)
elsif email_domain_blocked?([domain, *resolved_domains], options[:attempt_ip].is_a?(Symbol) ? record.public_send(options[:attempt_ip]) : nil)
record.errors.add(attribute, :blocked)
end
end
end

View File

@ -3,109 +3,101 @@
require 'rails_helper'
RSpec.describe EmailMxValidator do
let(:user) { Fabricate.build :user, email: }
let(:email) { 'foo@example.com' }
let(:resolv_dns_double) { instance_double(Resolv::DNS) }
subject { record_class.new }
context 'with an e-mail domain that is explicitly allowed' do
around do |example|
original = Rails.configuration.x.email_domains_allowlist
Rails.configuration.x.email_domains_allowlist = 'example.com'
example.run
Rails.configuration.x.email_domains_allowlist = original
end
context 'with no options' do
let(:record_class) do
Class.new do
include ActiveModel::Validations
context 'when there are not DNS records' do
before { configure_resolver('example.com') }
def self.name = 'Record'
it 'does not add errors to record' do
subject.validate(user)
expect(user.errors).to be_empty
attr_accessor :email
validates :email, email_mx: true
end
end
end
context 'when there are DNS records for the domain' do
before { configure_resolver('example.com', a: resolv_double_a('192.0.2.42')) }
let(:user) { Fabricate.build :user, email: }
let(:email) { 'foo@example.com' }
let(:resolv_dns_double) { instance_double(Resolv::DNS) }
it 'does not add errors to record' do
subject.validate(user)
expect(user.errors).to be_empty
end
end
context 'with an e-mail domain that is explicitly allowed' do
around do |example|
original = Rails.configuration.x.email_domains_allowlist
Rails.configuration.x.email_domains_allowlist = 'example.com'
example.run
Rails.configuration.x.email_domains_allowlist = original
end
context 'when the TagManager fails to normalize the domain' do
before do
allow(TagManager).to receive(:instance).and_return(tag_manage_double)
allow(tag_manage_double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError)
context 'when there are not DNS records' do
before { configure_resolver('example.com') }
it { is_expected.to allow_value(email).for(:email) }
end
end
let(:tag_manage_double) { instance_double(TagManager) }
context 'when there are DNS records for the domain' do
before { configure_resolver('example.com', a: resolv_double_a('192.0.2.42')) }
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
end
end
context 'when the email portion is blank' do
let(:email) { 'foo@' }
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
end
end
context 'when the email domain contains empty labels' do
let(:email) { 'foo@example..com' }
before { configure_resolver('example..com', a: resolv_double_a('192.0.2.42')) }
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
end
end
context 'when there are no DNS records for the email domain' do
before { configure_resolver('example.com') }
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
end
end
context 'when MX record does not lead to an IP' do
before do
configure_resolver('example.com', mx: resolv_double_mx('mail.example.com'))
configure_resolver('mail.example.com')
it { is_expected.to allow_value(email).for(:email) }
end
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
end
end
context 'when the TagManager fails to normalize the domain' do
before do
allow(TagManager).to receive(:instance).and_return(tag_manage_double)
allow(tag_manage_double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError)
end
context 'when the MX record has an email domain block' do
before do
Fabricate :email_domain_block, domain: 'mail.example.com'
configure_resolver(
'example.com',
mx: resolv_double_mx('mail.example.com')
)
configure_resolver(
'mail.example.com',
a: instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5'),
aaaa: instance_double(Resolv::DNS::Resource::IN::AAAA, address: 'fd00::2')
)
let(:tag_manage_double) { instance_double(TagManager) }
it { is_expected.to_not allow_value(email).for(:email) }
end
it 'adds errors to record' do
subject.validate(user)
expect(user.errors).to be_present
context 'when the email portion is blank' do
let(:email) { 'foo@' }
it { is_expected.to_not allow_value(email).for(:email) }
end
context 'when the email domain contains empty labels' do
let(:email) { 'foo@example..com' }
before { configure_resolver('example..com', a: resolv_double_a('192.0.2.42')) }
it { is_expected.to_not allow_value(email).for(:email) }
end
context 'when there are no DNS records for the email domain' do
before { configure_resolver('example.com') }
it { is_expected.to_not allow_value(email).for(:email) }
end
context 'when MX record does not lead to an IP' do
before do
configure_resolver('example.com', mx: resolv_double_mx('mail.example.com'))
configure_resolver('mail.example.com')
end
it { is_expected.to_not allow_value(email).for(:email) }
end
context 'when the MX record has an email domain block' do
before do
Fabricate :email_domain_block, domain: 'mail.example.com'
configure_resolver(
'example.com',
mx: resolv_double_mx('mail.example.com')
)
configure_resolver(
'mail.example.com',
a: instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5'),
aaaa: instance_double(Resolv::DNS::Resource::IN::AAAA, address: 'fd00::2')
)
end
it { is_expected.to_not allow_value(email).for(:email) }
end
end