Skip to content
This repository has been archived by the owner on Dec 21, 2023. It is now read-only.

Commit

Permalink
Change e-mail domain blocks to block IPs dynamically (mastodon#17635)
Browse files Browse the repository at this point in the history
* Change e-mail domain blocks to block IPs dynamically

* Update app/workers/scheduler/email_domain_block_refresh_scheduler.rb

Co-authored-by: Yamagishi Kazutoshi <[email protected]>

* Update app/workers/scheduler/email_domain_block_refresh_scheduler.rb

Co-authored-by: Yamagishi Kazutoshi <[email protected]>

Co-authored-by: Yamagishi Kazutoshi <[email protected]>
  • Loading branch information
Gargron and ykzts authored Feb 24, 2022
1 parent 91cc8d1 commit a29a982
Show file tree
Hide file tree
Showing 20 changed files with 325 additions and 160 deletions.
72 changes: 44 additions & 28 deletions app/controllers/admin/email_domain_blocks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ class EmailDomainBlocksController < BaseController

def index
authorize :email_domain_block, :index?

@email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page])
@form = Form::EmailDomainBlockBatch.new
end

def batch
@form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.email_domain_blocks.no_email_domain_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_email_domain_blocks_path
end

def new
Expand All @@ -19,41 +32,25 @@ def create

@email_domain_block = EmailDomainBlock.new(resource_params)

if @email_domain_block.save
log_action :create, @email_domain_block

if @email_domain_block.with_dns_records?
hostnames = []
ips = []

Resolv::DNS.open do |dns|
dns.timeouts = 5
if action_from_button == 'save'
EmailDomainBlock.transaction do
@email_domain_block.save!
log_action :create, @email_domain_block

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

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

(hostnames + ips).each do |hostname|
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block)
log_action :create, another_email_domain_block if another_email_domain_block.save
(@email_domain_block.other_domains || []).uniq.each do |domain|
other_email_domain_block = EmailDomainBlock.create!(domain: domain, parent: @email_domain_block)
log_action :create, other_email_domain_block
end
end

redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
else
set_resolved_records
render :new
end
end

def destroy
authorize @email_domain_block, :destroy?
@email_domain_block.destroy!
log_action :destroy, @email_domain_block
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
rescue ActiveRecord::RecordInvalid
set_resolved_records
render :new
end

private
Expand All @@ -62,8 +59,27 @@ def set_email_domain_block
@email_domain_block = EmailDomainBlock.find(params[:id])
end

def set_resolved_records
Resolv::DNS.open do |dns|
dns.timeouts = 5
@resolved_records = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a
end
end

def resource_params
params.require(:email_domain_block).permit(:domain, :with_dns_records)
params.require(:email_domain_block).permit(:domain, other_domains: [])
end

def form_email_domain_block_batch_params
params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: [])
end

def action_from_button
if params[:delete]
'delete'
elsif params[:save]
'save'
end
end
end
end
55 changes: 36 additions & 19 deletions app/models/email_domain_block.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
#
# Table name: email_domain_blocks
#
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# parent_id :bigint(8)
# id :bigint(8) not null, primary key
# domain :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# parent_id :bigint(8)
# ips :inet is an Array
# last_refresh_at :datetime
#

class EmailDomainBlock < ApplicationRecord
Expand All @@ -18,27 +20,42 @@ class EmailDomainBlock < ApplicationRecord

validates :domain, presence: true, uniqueness: true, domain: true

def with_dns_records=(val)
@with_dns_records = ActiveModel::Type::Boolean.new.cast(val)
end
# Used for adding multiple blocks at once
attr_accessor :other_domains

def with_dns_records?
@with_dns_records
def history
@history ||= Trends::History.new('email_domain_blocks', id)
end

alias with_dns_records with_dns_records?
def self.block?(domain_or_domains, ips: [], attempt_ip: nil)
domains = Array(domain_or_domains).map do |str|
domain = begin
if str.include?('@')
str.split('@', 2).last
else
str
end
end

TagManager.instance.normalize_domain(domain) if domain.present?
rescue Addressable::URI::InvalidURIError
nil
end

def self.block?(email)
_, domain = email.split('@', 2)
# If some of the inputs passed in are invalid, we definitely want to
# block the attempt, but we also want to register hits against any
# other valid matches

return true if domain.nil?
blocked = domains.any?(&:nil?)

begin
domain = TagManager.instance.normalize_domain(domain)
rescue Addressable::URI::InvalidURIError
return true
scope = where(domain: domains)
scope = scope.or(where('ips && ARRAY[?]::inet[]', ips)) if ips.any?

scope.find_each do |block|
blocked = true
block.history.add(attempt_ip) if attempt_ip.present?
end

where(domain: domain).exists?
blocked
end
end
30 changes: 30 additions & 0 deletions app/models/form/email_domain_block_batch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

class Form::EmailDomainBlockBatch
include ActiveModel::Model
include Authorization
include AccountableConcern

attr_accessor :email_domain_block_ids, :action, :current_account

def save
case action
when 'delete'
delete!
end
end

private

def email_domain_blocks
@email_domain_blocks ||= EmailDomainBlock.where(id: email_domain_block_ids)
end

def delete!
email_domain_blocks.each do |email_domain_block|
authorize(email_domain_block, :destroy?)
email_domain_block.destroy!
log_action :destroy, email_domain_block
end
end
end
1 change: 1 addition & 0 deletions app/models/status.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
# poll_id :bigint(8)
# deleted_at :datetime
# edited_at :datetime
# trendable :boolean
#

class Status < ApplicationRecord
Expand Down
26 changes: 12 additions & 14 deletions app/validators/blacklisted_email_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,39 @@ class BlacklistedEmailValidator < ActiveModel::Validator
def validate(user)
return if user.valid_invitation? || user.email.blank?

@email = user.email

user.errors.add(:email, :blocked) if blocked_email_provider?
user.errors.add(:email, :taken) if blocked_canonical_email?
user.errors.add(:email, :blocked) if blocked_email_provider?(user.email, user.sign_up_ip)
user.errors.add(:email, :taken) if blocked_canonical_email?(user.email)
end

private

def blocked_email_provider?
disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration?
def blocked_email_provider?(email, ip)
disallowed_through_email_domain_block?(email, ip) || disallowed_through_configuration?(email) || not_allowed_through_configuration?(email)
end

def blocked_canonical_email?
CanonicalEmailBlock.block?(@email)
def blocked_canonical_email?(email)
CanonicalEmailBlock.block?(email)
end

def disallowed_through_email_domain_block?
EmailDomainBlock.block?(@email)
def disallowed_through_email_domain_block?(email, ip)
EmailDomainBlock.block?(email, attempt_ip: ip)
end

def not_allowed_through_configuration?
def not_allowed_through_configuration?(email)
return false if Rails.configuration.x.email_domains_whitelist.blank?

domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
regexp = Regexp.new("@(.+\\.)?(#{domains})$", true)

@email !~ regexp
email !~ regexp
end

def disallowed_through_configuration?
def disallowed_through_configuration?(email)
return false if Rails.configuration.x.email_domains_blacklist.blank?

domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
regexp = Regexp.new("@(.+\\.)?(#{domains})", true)

regexp.match?(@email)
regexp.match?(email)
end
end
20 changes: 10 additions & 10 deletions app/validators/email_mx_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ def validate(user)
if domain.blank?
user.errors.add(:email, :invalid)
elsif !on_allowlist?(domain)
ips, hostnames = resolve_mx(domain)
resolved_ips, resolved_domains = resolve_mx(domain)

if ips.empty?
if resolved_ips.empty?
user.errors.add(:email, :unreachable)
elsif on_blacklist?(hostnames + ips)
elsif on_blacklist?(resolved_domains, resolved_ips, user.sign_up_ip)
user.errors.add(:email, :blocked)
end
end
Expand All @@ -40,24 +40,24 @@ def on_allowlist?(domain)
end

def resolve_mx(domain)
hostnames = []
ips = []
records = []
ips = []

Resolv::DNS.open do |dns|
dns.timeouts = 5

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

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

[ips, hostnames]
[ips, records]
end

def on_blacklist?(values)
EmailDomainBlock.where(domain: values.uniq).any?
def on_blacklist?(domains, resolved_ips, attempt_ip)
EmailDomainBlock.block?(domains, ips: resolved_ips, attempt_ip: attempt_ip)
end
end
27 changes: 13 additions & 14 deletions app/views/admin/email_domain_blocks/_email_domain_block.html.haml
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
%tr
%td
%samp= email_domain_block.domain
%td
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete
.batch-table__row
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
= f.check_box :email_domain_block_ids, { multiple: true, include_hidden: false }, email_domain_block.id
.batch-table__row__content.pending-account
.pending-account__header
%samp= link_to email_domain_block.domain, admin_accounts_path(email: "%@#{email_domain_block.domain}")

- email_domain_block.children.each do |child_email_domain_block|
%tr
%td
%samp= child_email_domain_block.domain
%span.muted-hint
= surround '(', ')' do
= t('admin.email_domain_blocks.from_html', domain: content_tag(:samp, email_domain_block.domain))
%td
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(child_email_domain_block), method: :delete
%br/

- if email_domain_block.parent.present?
= t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))

= t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
28 changes: 17 additions & 11 deletions app/views/admin/email_domain_blocks/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@
- content_for :heading_actions do
= link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'

- if @email_domain_blocks.empty?
%div.muted-hint.center-text=t 'admin.email_domain_blocks.empty'
- else
.table-wrapper
%table.table
%thead
%tr
%th= t('admin.email_domain_blocks.domain')
%th
%tbody
= render partial: 'email_domain_block', collection: @email_domain_blocks
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'

= form_for(@form, url: batch_admin_email_domain_blocks_path) do |f|
= hidden_field_tag :page, params[:page] || 1

.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([fa_icon('times'), t('admin.email_domain_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body
- if @email_domain_blocks.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'email_domain_block', collection: @email_domain_blocks.flat_map { |x| [x, x.children.to_a].flatten }, locals: { f: f }

= paginate @email_domain_blocks
Loading

0 comments on commit a29a982

Please sign in to comment.