Skip to content

Commit

Permalink
Improve e-mail MX validator and add tests (mastodon#9489)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gargron authored and hiyuki2578 committed Oct 2, 2019
1 parent 2c4a319 commit 1a6dead
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 6 deletions.
6 changes: 5 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class User < ApplicationRecord

validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed?
validates_with EmailMxValidator, if: :email_changed?
validates_with EmailMxValidator, if: :validate_email_dns?

scope :recent, -> { order(id: :desc) }
scope :admins, -> { where(admin: true) }
Expand Down Expand Up @@ -360,4 +360,8 @@ def regenerate_feed!
def needs_feed_update?
last_sign_in_at < ACTIVE_DURATION.ago
end

def validate_email_dns?
email_changed? && !(Rails.env.test? || Rails.env.development?)
end
end
19 changes: 14 additions & 5 deletions app/validators/email_mx_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

class EmailMxValidator < ActiveModel::Validator
def validate(user)
return if Rails.env.test? || Rails.env.development?
user.errors.add(:email, I18n.t('users.invalid_email')) if invalid_mx?(user.email)
end

Expand All @@ -15,13 +14,23 @@ def invalid_mx?(value)

return true if domain.nil?

records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
records = Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s } if records.empty?
hostnames = []
ips = []

records.empty? || on_blacklist?(records)
Resolv::DNS.open do |dns|
dns.timeouts = 1

hostnames = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }

([domain] + hostnames).uniq.each do |hostname|
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
end
end

ips.empty? || on_blacklist?(hostnames + ips)
end

def on_blacklist?(values)
EmailDomainBlock.where(domain: values).any?
EmailDomainBlock.where(domain: values.uniq).any?
end
end
75 changes: 75 additions & 0 deletions spec/validators/email_mx_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

require 'rails_helper'

describe EmailMxValidator do
describe '#validate' do
let(:user) { double(email: 'foo@example.com', errors: double(add: nil)) }

it 'adds an error if there are no DNS records for the e-mail domain' do
resolver = double

allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)

subject.validate(user)
expect(user.errors).to have_received(:add)
end

it 'adds an error if a MX record exists but does not lead to an IP' do
resolver = double

allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)

subject.validate(user)
expect(user.errors).to have_received(:add)
end

it 'adds an error if the A record is blacklisted' do
EmailDomainBlock.create!(domain: '1.2.3.4')
resolver = double

allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '1.2.3.4')])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)

subject.validate(user)
expect(user.errors).to have_received(:add)
end

it 'adds an error if the MX record is blacklisted' do
EmailDomainBlock.create!(domain: '2.3.4.5')
resolver = double

allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)

subject.validate(user)
expect(user.errors).to have_received(:add)
end

it 'adds an error if the MX hostname is blacklisted' do
EmailDomainBlock.create!(domain: 'mail.example.com')
resolver = double

allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)

subject.validate(user)
expect(user.errors).to have_received(:add)
end
end
end

0 comments on commit 1a6dead

Please sign in to comment.