From 7d985f2aac639dc5fae528db2dbc4422ca10f276 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 8 Oct 2020 00:34:57 +0200 Subject: Remove dependency on goldfinger gem (#14919) There are edge cases where requests to certain hosts timeout when using the vanilla HTTP.rb gem, which the goldfinger gem uses. Now that we no longer need to support OStatus servers, webfinger logic is so simple that there is no point encapsulating it in a gem, so we can just use our own Request class. With that, we benefit from more robust timeout code and IPv4/IPv6 resolution. Fix #14091 --- app/helpers/webfinger_helper.rb | 33 +-------------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb index ab7ca4698..482f4e19e 100644 --- a/app/helpers/webfinger_helper.rb +++ b/app/helpers/webfinger_helper.rb @@ -1,38 +1,7 @@ # frozen_string_literal: true -# Monkey-patch on monkey-patch. -# Because it conflicts with the request.rb patch. -class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation - def connect(socket_class, host, port, nodelay = false) - ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do - @socket = socket_class.open(host, port) - @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay - end - end -end - module WebfingerHelper def webfinger!(uri) - hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) - - raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri - - opts = { - ssl: !hidden_service_uri, - - headers: { - 'User-Agent': Mastodon::Version.user_agent, - }, - - timeout_class: HTTP::Timeout::PerOperationOriginal, - - timeout_options: { - write_timeout: 10, - connect_timeout: 5, - read_timeout: 10, - }, - } - - Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger + Webfinger.new(uri).perform end end -- cgit From 5e1364c448222c964faa469b6b5bfe9adf701c1a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Oct 2020 16:33:49 +0200 Subject: Add IP-based rules (#14963) --- app/controllers/admin/ip_blocks_controller.rb | 56 +++++++++ app/controllers/api/v1/accounts_controller.rb | 2 +- app/controllers/auth/registrations_controller.rb | 6 +- app/helpers/admin/action_logs_helper.rb | 4 + app/lib/fast_ip_map.rb | 32 +++++ app/models/concerns/expireable.rb | 10 +- app/models/form/ip_block_batch.rb | 31 +++++ app/models/ip_block.rb | 41 +++++++ app/models/user.rb | 16 ++- app/policies/ip_block_policy.rb | 15 +++ app/services/app_sign_up_service.rb | 4 +- app/views/admin/ip_blocks/_ip_block.html.haml | 11 ++ app/views/admin/ip_blocks/index.html.haml | 28 +++++ app/views/admin/ip_blocks/new.html.haml | 20 ++++ .../admin/pending_accounts/_account.html.haml | 2 +- app/workers/scheduler/ip_cleanup_scheduler.rb | 18 ++- config/initializers/rack_attack.rb | 4 + config/locales/en.yml | 19 +++ config/locales/simple_form.en.yml | 15 +++ config/navigation.rb | 1 + config/routes.rb | 6 + db/migrate/20201008202037_create_ip_blocks.rb | 12 ++ .../20201008220312_add_sign_up_ip_to_users.rb | 5 + db/schema.rb | 12 +- lib/cli.rb | 4 + lib/mastodon/ip_blocks_cli.rb | 132 +++++++++++++++++++++ spec/fabricators/ip_block_fabricator.rb | 6 + spec/lib/fast_ip_map_spec.rb | 21 ++++ spec/models/ip_block_spec.rb | 5 + spec/services/app_sign_up_service_spec.rb | 13 +- 30 files changed, 530 insertions(+), 21 deletions(-) create mode 100644 app/controllers/admin/ip_blocks_controller.rb create mode 100644 app/lib/fast_ip_map.rb create mode 100644 app/models/form/ip_block_batch.rb create mode 100644 app/models/ip_block.rb create mode 100644 app/policies/ip_block_policy.rb create mode 100644 app/views/admin/ip_blocks/_ip_block.html.haml create mode 100644 app/views/admin/ip_blocks/index.html.haml create mode 100644 app/views/admin/ip_blocks/new.html.haml create mode 100644 db/migrate/20201008202037_create_ip_blocks.rb create mode 100644 db/migrate/20201008220312_add_sign_up_ip_to_users.rb create mode 100644 lib/mastodon/ip_blocks_cli.rb create mode 100644 spec/fabricators/ip_block_fabricator.rb create mode 100644 spec/lib/fast_ip_map_spec.rb create mode 100644 spec/models/ip_block_spec.rb (limited to 'app/helpers') 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] 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(#), 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 -- cgit