about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2020-10-12 16:33:49 +0200
committerGitHub <noreply@github.com>2020-10-12 16:33:49 +0200
commit5e1364c448222c964faa469b6b5bfe9adf701c1a (patch)
treebf13de38f07f6a8ec4bdce9c6242c3c472bfddea
parentdc52a778e111a67a5275dd4afecf3991e279e005 (diff)
Add IP-based rules (#14963)
-rw-r--r--app/controllers/admin/ip_blocks_controller.rb56
-rw-r--r--app/controllers/api/v1/accounts_controller.rb2
-rw-r--r--app/controllers/auth/registrations_controller.rb6
-rw-r--r--app/helpers/admin/action_logs_helper.rb4
-rw-r--r--app/lib/fast_ip_map.rb32
-rw-r--r--app/models/concerns/expireable.rb10
-rw-r--r--app/models/form/ip_block_batch.rb31
-rw-r--r--app/models/ip_block.rb41
-rw-r--r--app/models/user.rb16
-rw-r--r--app/policies/ip_block_policy.rb15
-rw-r--r--app/services/app_sign_up_service.rb4
-rw-r--r--app/views/admin/ip_blocks/_ip_block.html.haml11
-rw-r--r--app/views/admin/ip_blocks/index.html.haml28
-rw-r--r--app/views/admin/ip_blocks/new.html.haml20
-rw-r--r--app/views/admin/pending_accounts/_account.html.haml2
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb18
-rw-r--r--config/initializers/rack_attack.rb4
-rw-r--r--config/locales/en.yml19
-rw-r--r--config/locales/simple_form.en.yml15
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb6
-rw-r--r--db/migrate/20201008202037_create_ip_blocks.rb12
-rw-r--r--db/migrate/20201008220312_add_sign_up_ip_to_users.rb5
-rw-r--r--db/schema.rb12
-rw-r--r--lib/cli.rb4
-rw-r--r--lib/mastodon/ip_blocks_cli.rb132
-rw-r--r--spec/fabricators/ip_block_fabricator.rb6
-rw-r--r--spec/lib/fast_ip_map_spec.rb21
-rw-r--r--spec/models/ip_block_spec.rb5
-rw-r--r--spec/services/app_sign_up_service_spec.rb13
30 files changed, 530 insertions, 21 deletions
diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb
new file mode 100644
index 000000000..92b8b0d2b
--- /dev/null
+++ b/app/controllers/admin/ip_blocks_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+  class IpBlocksController < BaseController
+    def index
+      authorize :ip_block, :index?
+
+      @ip_blocks = IpBlock.page(params[:page])
+      @form      = Form::IpBlockBatch.new
+    end
+
+    def new
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
+    end
+
+    def create
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(resource_params)
+
+      if @ip_block.save
+        log_action :create, @ip_block
+        redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
+      else
+        render :new
+      end
+    end
+
+    def batch
+      @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
+    rescue Mastodon::NotPermittedError
+      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    ensure
+      redirect_to admin_ip_blocks_path
+    end
+
+    private
+
+    def resource_params
+      params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
+    end
+
+    def action_from_button
+      'delete' if params[:delete]
+    end
+
+    def form_ip_block_batch_params
+      params.require(:form_ip_block_batch).permit(ip_block_ids: [])
+    end
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index aef51a647..4a97f0251 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def create
-    token    = AppSignUpService.new.call(doorkeeper_token.application, account_params)
+    token    = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
     response = Doorkeeper::OAuth::TokenResponse.new(token)
 
     headers.merge!(response.headers)
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index d31966248..eb0924190 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -45,9 +45,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   def build_resource(hash = nil)
     super(hash)
 
-    resource.locale             = I18n.locale
-    resource.invite_code        = params[:invite_code] if resource.invite_code.blank?
-    resource.current_sign_in_ip = request.remote_ip
+    resource.locale      = I18n.locale
+    resource.invite_code = params[:invite_code] if resource.invite_code.blank?
+    resource.sign_up_ip  = request.remote_ip
 
     resource.build_account if resource.account.nil?
   end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 8e398c3b2..0f3ca36e2 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
       link_to record.target_account.acct, admin_account_path(record.target_account_id)
     when 'Announcement'
       link_to truncate(record.text), edit_admin_announcement_path(record.id)
+    when 'IpBlock'
+      "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
     end
   end
 
@@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
       end
     when 'Announcement'
       truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
+    when 'IpBlock'
+      "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
     end
   end
 end
diff --git a/app/lib/fast_ip_map.rb b/app/lib/fast_ip_map.rb
new file mode 100644
index 000000000..ba30b45f3
--- /dev/null
+++ b/app/lib/fast_ip_map.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class FastIpMap
+  MAX_IPV4_PREFIX = 32
+  MAX_IPV6_PREFIX = 128
+
+  # @param [Enumerable<IPAddr>] addresses
+  def initialize(addresses)
+    @fast_lookup = {}
+    @ranges      = []
+
+    # Hash look-up is faster but only works for exact matches, so we split
+    # exact addresses from non-exact ones
+    addresses.each do |address|
+      if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX)
+        @fast_lookup[address.to_s] = true
+      else
+        @ranges << address
+      end
+    end
+
+    # We're more likely to hit wider-reaching ranges when checking for
+    # inclusion, so make sure they're sorted first
+    @ranges.sort_by!(&:prefix)
+  end
+
+  # @param [IPAddr] address
+  # @return [Boolean]
+  def include?(address)
+    @fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) }
+  end
+end
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index f7d2bab49..a66a4661b 100644
--- a/app/models/concerns/expireable.rb
+++ b/app/models/concerns/expireable.rb
@@ -6,7 +6,15 @@ module Expireable
   included do
     scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
 
-    attr_reader :expires_in
+    def expires_in
+      return @expires_in if defined?(@expires_in)
+
+      if expires_at.nil?
+        nil
+      else
+        (expires_at - created_at).to_i
+      end
+    end
 
     def expires_in=(interval)
       self.expires_at = interval.to_i.seconds.from_now if interval.present?
diff --git a/app/models/form/ip_block_batch.rb b/app/models/form/ip_block_batch.rb
new file mode 100644
index 000000000..f6fe9b593
--- /dev/null
+++ b/app/models/form/ip_block_batch.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Form::IpBlockBatch
+  include ActiveModel::Model
+  include Authorization
+  include AccountableConcern
+
+  attr_accessor :ip_block_ids, :action, :current_account
+
+  def save
+    case action
+    when 'delete'
+      delete!
+    end
+  end
+
+  private
+
+  def ip_blocks
+    @ip_blocks ||= IpBlock.where(id: ip_block_ids)
+  end
+
+  def delete!
+    ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) }
+
+    ip_blocks.each do |ip_block|
+      ip_block.destroy
+      log_action :destroy, ip_block
+    end
+  end
+end
diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb
new file mode 100644
index 000000000..aedd3ca0d
--- /dev/null
+++ b/app/models/ip_block.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: ip_blocks
+#
+#  id         :bigint(8)        not null, primary key
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  expires_at :datetime
+#  ip         :inet             default(#<IPAddr: IPv4:0.0.0.0/255.255.255.255>), not null
+#  severity   :integer          default(NULL), not null
+#  comment    :text             default(""), not null
+#
+
+class IpBlock < ApplicationRecord
+  CACHE_KEY = 'blocked_ips'
+
+  include Expireable
+
+  enum severity: {
+    sign_up_requires_approval: 5000,
+    no_access: 9999,
+  }
+
+  validates :ip, :severity, presence: true
+
+  after_commit :reset_cache
+
+  class << self
+    def blocked?(remote_ip)
+      blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }
+      blocked_ips_map.include?(remote_ip)
+    end
+  end
+
+  private
+
+  def reset_cache
+    Rails.cache.delete(CACHE_KEY)
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 2c460e1fd..7c8124fed 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,6 +41,7 @@
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
+#  sign_up_ip                :inet
 #
 
 class User < ApplicationRecord
@@ -97,7 +98,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
-  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
+  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
@@ -331,6 +332,7 @@ class User < ApplicationRecord
 
       arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
       arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
+      arr << [created_at, sign_up_ip] if sign_up_ip.present?
 
       arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
     end
@@ -385,7 +387,17 @@ class User < ApplicationRecord
   end
 
   def set_approved
-    self.approved = open_registrations? || valid_invitation? || external?
+    self.approved = begin
+      if sign_up_from_ip_requires_approval?
+        false
+      else
+        open_registrations? || valid_invitation? || external?
+      end
+    end
+  end
+
+  def sign_up_from_ip_requires_approval?
+    !sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists?
   end
 
   def open_registrations?
diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb
new file mode 100644
index 000000000..34dbd746a
--- /dev/null
+++ b/app/policies/ip_block_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class IpBlockPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def create?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index c9739c77d..e00694157 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
 class AppSignUpService < BaseService
-  def call(app, params)
+  def call(app, remote_ip, params)
     return unless allowed_registrations?
 
     user_params           = params.slice(:email, :password, :agreement, :locale)
     account_params        = params.slice(:username)
     invite_request_params = { text: params[:reason] }
-    user                  = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
+    user                  = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
 
     Doorkeeper::AccessToken.create!(application: app,
                                     resource_owner_id: user.id,
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
new file mode 100644
index 000000000..e07e2b444
--- /dev/null
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -0,0 +1,11 @@
+.batch-table__row
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
+  .batch-table__row__content
+    .batch-table__row__content__text
+      %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
+      - if ip_block.comment.present?
+        •
+        = ip_block.comment
+      %br/
+      = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml
new file mode 100644
index 000000000..a282a4cfe
--- /dev/null
+++ b/app/views/admin/ip_blocks/index.html.haml
@@ -0,0 +1,28 @@
+- content_for :page_title do
+  = t('admin.ip_blocks.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
+
+- if can?(:create, :ip_block)
+  - content_for :heading_actions do
+    = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button'
+
+= form_for(@form, url: batch_admin_ip_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
+        - if can?(:destroy, :ip_block)
+          = f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @ip_blocks.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'ip_block', collection: @ip_blocks, locals: { f: f }
+
+= paginate @ip_blocks
+
diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml
new file mode 100644
index 000000000..69f6b98b9
--- /dev/null
+++ b/app/views/admin/ip_blocks/new.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @ip_block, url: admin_ip_blocks_path do |f|
+  = render 'shared/error_messages', object: @ip_block
+
+  .fields-group
+    = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' }
+
+  .fields-group
+    = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+
+  .fields-group
+    = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) }
+
+  .fields-group
+    = f.input :comment, as: :string, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('admin.ip_blocks.add_new'), type: :submit
diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml
index 7a9796a67..5b475b59a 100644
--- a/app/views/admin/pending_accounts/_account.html.haml
+++ b/app/views/admin/pending_accounts/_account.html.haml
@@ -7,7 +7,7 @@
         %strong= account.user_email
         = "(@#{account.username})"
       %br/
-      = account.user_current_sign_in_ip
+      %samp= account.user_current_sign_in_ip

       = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
 
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index 6d38b52a2..853f20e25 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -3,13 +3,23 @@
 class Scheduler::IpCleanupScheduler
   include Sidekiq::Worker
 
-  RETENTION_PERIOD = 1.year
+  IP_RETENTION_PERIOD = 1.year.freeze
 
   sidekiq_options lock: :until_executed, retry: 0
 
   def perform
-    time_ago = RETENTION_PERIOD.ago
-    SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all
-    User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil)
+    clean_ip_columns!
+    clean_expired_ip_blocks!
+  end
+
+  private
+
+  def clean_ip_columns!
+    SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
+    User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil)
+  end
+
+  def clean_expired_ip_blocks!
+    IpBlock.expired.in_batches.destroy_all
   end
 end
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index cd29afac5..6662ef40b 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -42,6 +42,10 @@ class Rack::Attack
     req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
   end
 
+  Rack::Attack.blocklist('deny from blocklist') do |req|
+    IpBlock.blocked?(req.remote_ip)
+  end
+
   throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
     req.authenticated_user_id if req.api_request?
   end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 427b2c3fc..084006a2a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -223,12 +223,14 @@ en:
         create_domain_allow: Create Domain Allow
         create_domain_block: Create Domain Block
         create_email_domain_block: Create E-mail Domain Block
+        create_ip_block: Create IP rule
         demote_user: Demote User
         destroy_announcement: Delete Announcement
         destroy_custom_emoji: Delete Custom Emoji
         destroy_domain_allow: Delete Domain Allow
         destroy_domain_block: Delete Domain Block
         destroy_email_domain_block: Delete e-mail domain block
+        destroy_ip_block: Delete IP rule
         destroy_status: Delete Status
         disable_2fa_user: Disable 2FA
         disable_custom_emoji: Disable Custom Emoji
@@ -259,12 +261,14 @@ en:
         create_domain_allow: "%{name} allowed federation with domain %{target}"
         create_domain_block: "%{name} blocked domain %{target}"
         create_email_domain_block: "%{name} blocked e-mail domain %{target}"
+        create_ip_block: "%{name} created rule for IP %{target}"
         demote_user: "%{name} demoted user %{target}"
         destroy_announcement: "%{name} deleted announcement %{target}"
         destroy_custom_emoji: "%{name} destroyed emoji %{target}"
         destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
         destroy_domain_block: "%{name} unblocked domain %{target}"
         destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}"
+        destroy_ip_block: "%{name} deleted rule for IP %{target}"
         destroy_status: "%{name} removed status by %{target}"
         disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
         disable_custom_emoji: "%{name} disabled emoji %{target}"
@@ -449,6 +453,21 @@ en:
         expired: Expired
         title: Filter
       title: Invites
+    ip_blocks:
+      add_new: Create rule
+      created_msg: Successfully added new IP rule
+      delete: Delete
+      expires_in:
+        '1209600': 2 weeks
+        '15778476': 6 months
+        '2629746': 1 month
+        '31556952': 1 year
+        '86400': 1 day
+        '94670856': 3 years
+      new:
+        title: Create new IP rule
+      no_ip_block_selected: No IP rules were changed as none were selected
+      title: IP rules
     pending_accounts:
       title: Pending accounts (%{count})
     relationships:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 9b0af6d24..b69487953 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -65,6 +65,14 @@ en:
         data: CSV file exported from another Mastodon server
       invite_request:
         text: This will help us review your application
+      ip_block:
+        comment: Optional. Remember why you added this rule.
+        expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended.
+        ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out!
+        severities:
+          no_access: Block access to all resources
+          sign_up_requires_approval: New sign-ups will require your approval
+        severity: Choose what will happen with requests from this IP
       sessions:
         otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
         webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
@@ -170,6 +178,13 @@ en:
         comment: Comment
       invite_request:
         text: Why do you want to join?
+      ip_block:
+        comment: Comment
+        ip: IP
+        severities:
+          no_access: Block access
+          sign_up_requires_approval: Limit sign-ups
+        severity: Rule
       notification_emails:
         digest: Send digest e-mails
         favourite: Someone favourited your status
diff --git a/config/navigation.rb b/config/navigation.rb
index c113a3c3e..4a56abe18 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -41,6 +41,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
       s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
       s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
+      s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
     end
 
     n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
diff --git a/config/routes.rb b/config/routes.rb
index 8d9bc317b..a21dbd45e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -283,6 +283,12 @@ Rails.application.routes.draw do
       end
     end
 
+    resources :ip_blocks, only: [:index, :new, :create] do
+      collection do
+        post :batch
+      end
+    end
+
     resources :account_moderation_notes, only: [:create, :destroy]
 
     resources :tags, only: [:index, :show, :update] do
diff --git a/db/migrate/20201008202037_create_ip_blocks.rb b/db/migrate/20201008202037_create_ip_blocks.rb
new file mode 100644
index 000000000..32acd6ede
--- /dev/null
+++ b/db/migrate/20201008202037_create_ip_blocks.rb
@@ -0,0 +1,12 @@
+class CreateIpBlocks < ActiveRecord::Migration[5.2]
+  def change
+    create_table :ip_blocks do |t|
+      t.inet :ip, null: false, default: '0.0.0.0'
+      t.integer :severity, null: false, default: 0
+      t.datetime :expires_at
+      t.text :comment, null: false, default: ''
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20201008220312_add_sign_up_ip_to_users.rb b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb
new file mode 100644
index 000000000..66cd624bb
--- /dev/null
+++ b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb
@@ -0,0 +1,5 @@
+class AddSignUpIpToUsers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :users, :sign_up_ip, :inet
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0029d620a..5805f3105 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_09_17_222734) do
+ActiveRecord::Schema.define(version: 2020_10_08_220312) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -463,6 +463,15 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do
     t.index ["user_id"], name: "index_invites_on_user_id"
   end
 
+  create_table "ip_blocks", force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.datetime "expires_at"
+    t.inet "ip", default: "0.0.0.0", null: false
+    t.integer "severity", default: 0, null: false
+    t.text "comment", default: "", null: false
+  end
+
   create_table "list_accounts", force: :cascade do |t|
     t.bigint "list_id", null: false
     t.bigint "account_id", null: false
@@ -891,6 +900,7 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do
     t.string "sign_in_token"
     t.datetime "sign_in_token_sent_at"
     t.string "webauthn_id"
+    t.inet "sign_up_ip"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
diff --git a/lib/cli.rb b/lib/cli.rb
index 9162144cc..2a4dd11b2 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli'
 require_relative 'mastodon/cache_cli'
 require_relative 'mastodon/upgrade_cli'
 require_relative 'mastodon/email_domain_blocks_cli'
+require_relative 'mastodon/ip_blocks_cli'
 require_relative 'mastodon/version'
 
 module Mastodon
@@ -57,6 +58,9 @@ module Mastodon
     desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
     subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
 
+    desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
+    subcommand 'ip_blocks', Mastodon::IpBlocksCLI
+
     option :dry_run, type: :boolean
     desc 'self-destruct', 'Erase the server from the federation'
     long_desc <<~LONG_DESC
diff --git a/lib/mastodon/ip_blocks_cli.rb b/lib/mastodon/ip_blocks_cli.rb
new file mode 100644
index 000000000..6aff36d90
--- /dev/null
+++ b/lib/mastodon/ip_blocks_cli.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+  class IpBlocksCLI < Thor
+    def self.exit_on_failure?
+      true
+    end
+
+    option :severity, required: true, enum: %w(no_access sign_up_requires_approval), desc: 'Severity of the block'
+    option :comment, aliases: [:c], desc: 'Optional comment'
+    option :duration, aliases: [:d], type: :numeric, desc: 'Duration of the block in seconds'
+    option :force, type: :boolean, aliases: [:f], desc: 'Overwrite existing blocks'
+    desc 'add IP...', 'Add one or more IP blocks'
+    long_desc <<-LONG_DESC
+      Add one or more IP blocks. You can use CIDR syntax to
+      block IP ranges. You must specify --severity of the block. All
+      options will be copied for each IP block you create in one command.
+
+      You can add a --comment. If an IP block already exists for one of
+      the provided IPs, it will be skipped unless you use the --force
+      option to overwrite it.
+    LONG_DESC
+    def add(*addresses)
+      if addresses.empty?
+        say('No IP(s) given', :red)
+        exit(1)
+      end
+
+      skipped   = 0
+      processed = 0
+      failed    = 0
+
+      addresses.each do |address|
+        ip_block = IpBlock.find_by(ip: address)
+
+        if ip_block.present? && !options[:force]
+          say("#{address} is already blocked", :yellow)
+          skipped += 1
+          next
+        end
+
+        ip_block ||= IpBlock.new(ip: address)
+
+        ip_block.severity   = options[:severity]
+        ip_block.comment    = options[:comment]
+        ip_block.expires_in = options[:duration]
+
+        if ip_block.save
+          processed += 1
+        else
+          say("#{address} could not be saved", :red)
+          failed += 1
+        end
+      end
+
+      say("Added #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed))
+    end
+
+    option :force, type: :boolean, aliases: [:f], desc: 'Remove blocks for ranges that cover given IP(s)'
+    desc 'remove IP...', 'Remove one or more IP blocks'
+    long_desc <<-LONG_DESC
+      Remove one or more IP blocks. Normally, only exact matches are removed. If
+      you want to ensure that all of the given IP addresses are unblocked, you
+      can use --force which will also remove any blocks for IP ranges that would
+      cover the given IP(s).
+    LONG_DESC
+    def remove(*addresses)
+      if addresses.empty?
+        say('No IP(s) given', :red)
+        exit(1)
+      end
+
+      processed = 0
+      skipped   = 0
+
+      addresses.each do |address|
+        ip_blocks = begin
+          if options[:force]
+            IpBlock.where('ip >>= ?', address)
+          else
+            IpBlock.where('ip <<= ?', address)
+          end
+        end
+
+        if ip_blocks.empty?
+          say("#{address} is not yet blocked", :yellow)
+          skipped += 1
+          next
+        end
+
+        ip_blocks.in_batches.destroy_all
+        processed += 1
+      end
+
+      say("Removed #{processed}, skipped #{skipped}", color(processed, 0))
+    end
+
+    option :format, aliases: [:f], enum: %w(plain nginx), desc: 'Format of the output'
+    desc 'export', 'Export blocked IPs'
+    long_desc <<-LONG_DESC
+      Export blocked IPs. Different formats are supported for usage with other
+      tools. Only blocks with no_access severity are returned.
+    LONG_DESC
+    def export
+      IpBlock.where(severity: :no_access).find_each do |ip_block|
+        case options[:format]
+        when 'nginx'
+          puts "deny #{ip_block.ip}/#{ip_block.ip.prefix};"
+        else
+          puts "#{ip_block.ip}/#{ip_block.ip.prefix}"
+        end
+      end
+    end
+
+    private
+
+    def color(processed, failed)
+      if !processed.zero? && failed.zero?
+        :green
+      elsif failed.zero?
+        :yellow
+      else
+        :red
+      end
+    end
+  end
+end
diff --git a/spec/fabricators/ip_block_fabricator.rb b/spec/fabricators/ip_block_fabricator.rb
new file mode 100644
index 000000000..31dc336e6
--- /dev/null
+++ b/spec/fabricators/ip_block_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:ip_block) do
+  ip         ""
+  severity   ""
+  expires_at "2020-10-08 22:20:37"
+  comment    "MyText"
+end
\ No newline at end of file
diff --git a/spec/lib/fast_ip_map_spec.rb b/spec/lib/fast_ip_map_spec.rb
new file mode 100644
index 000000000..c66f64828
--- /dev/null
+++ b/spec/lib/fast_ip_map_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe FastIpMap do
+  describe '#include?' do
+    subject { described_class.new([IPAddr.new('20.4.0.0/16'), IPAddr.new('145.22.30.0/24'), IPAddr.new('189.45.86.3')])}
+
+    it 'returns true for an exact match' do
+      expect(subject.include?(IPAddr.new('189.45.86.3'))).to be true
+    end
+
+    it 'returns true for a range match' do
+      expect(subject.include?(IPAddr.new('20.4.45.7'))).to be true
+    end
+
+    it 'returns false for no match' do
+      expect(subject.include?(IPAddr.new('145.22.40.64'))).to be false
+    end
+  end
+end
diff --git a/spec/models/ip_block_spec.rb b/spec/models/ip_block_spec.rb
new file mode 100644
index 000000000..6603c6417
--- /dev/null
+++ b/spec/models/ip_block_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe IpBlock, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb
index e7c7f3ba1..e0c83b704 100644
--- a/spec/services/app_sign_up_service_spec.rb
+++ b/spec/services/app_sign_up_service_spec.rb
@@ -3,6 +3,7 @@ require 'rails_helper'
 RSpec.describe AppSignUpService, type: :service do
   let(:app) { Fabricate(:application, scopes: 'read write') }
   let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } }
+  let(:remote_ip) { IPAddr.new('198.0.2.1') }
 
   subject { described_class.new }
 
@@ -10,16 +11,16 @@ RSpec.describe AppSignUpService, type: :service do
     it 'returns nil when registrations are closed' do
       tmp = Setting.registrations_mode
       Setting.registrations_mode = 'none'
-      expect(subject.call(app, good_params)).to be_nil
+      expect(subject.call(app, remote_ip, good_params)).to be_nil
       Setting.registrations_mode = tmp
     end
 
     it 'raises an error when params are missing' do
-      expect { subject.call(app, {}) }.to raise_error ActiveRecord::RecordInvalid
+      expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid
     end
 
     it 'creates an unconfirmed user with access token' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil
@@ -27,13 +28,13 @@ RSpec.describe AppSignUpService, type: :service do
     end
 
     it 'creates access token with the app\'s scopes' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       expect(access_token.scopes.to_s).to eq 'read write'
     end
 
     it 'creates an account' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil
@@ -42,7 +43,7 @@ RSpec.describe AppSignUpService, type: :service do
     end
 
     it 'creates an account with invite request text' do
-      access_token = subject.call(app, good_params.merge(reason: 'Foo bar'))
+      access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar'))
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil