Add IP-based rules (#14963)

This commit is contained in:
Eugen Rochko 2020-10-12 16:33:49 +02:00 committed by GitHub
parent dc52a778e1
commit 5e1364c448
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 530 additions and 21 deletions

View File

@ -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

View File

@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def create 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) response = Doorkeeper::OAuth::TokenResponse.new(token)
headers.merge!(response.headers) headers.merge!(response.headers)

View File

@ -45,9 +45,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def build_resource(hash = nil) def build_resource(hash = nil)
super(hash) super(hash)
resource.locale = I18n.locale resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.current_sign_in_ip = request.remote_ip resource.sign_up_ip = request.remote_ip
resource.build_account if resource.account.nil? resource.build_account if resource.account.nil?
end end

View File

@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
link_to record.target_account.acct, admin_account_path(record.target_account_id) link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement' when 'Announcement'
link_to truncate(record.text), edit_admin_announcement_path(record.id) 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
end end
@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
end end
when 'Announcement' when 'Announcement'
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) 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 end
end end

32
app/lib/fast_ip_map.rb Normal file
View File

@ -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

View File

@ -6,7 +6,15 @@ module Expireable
included do included do
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } 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) def expires_in=(interval)
self.expires_at = interval.to_i.seconds.from_now if interval.present? self.expires_at = interval.to_i.seconds.from_now if interval.present?

View File

@ -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

41
app/models/ip_block.rb Normal file
View File

@ -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

View File

@ -41,6 +41,7 @@
# sign_in_token :string # sign_in_token :string
# sign_in_token_sent_at :datetime # sign_in_token_sent_at :datetime
# webauthn_id :string # webauthn_id :string
# sign_up_ip :inet
# #
class User < ApplicationRecord class User < ApplicationRecord
@ -97,7 +98,7 @@ class User < ApplicationRecord
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } 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 :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_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) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
before_validation :sanitize_languages 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 << [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 << [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! arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
end end
@ -385,7 +387,17 @@ class User < ApplicationRecord
end end
def set_approved 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 end
def open_registrations? def open_registrations?

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class IpBlockPolicy < ApplicationPolicy
def index?
admin?
end
def create?
admin?
end
def destroy?
admin?
end
end

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class AppSignUpService < BaseService class AppSignUpService < BaseService
def call(app, params) def call(app, remote_ip, params)
return unless allowed_registrations? return unless allowed_registrations?
user_params = params.slice(:email, :password, :agreement, :locale) user_params = params.slice(:email, :password, :agreement, :locale)
account_params = params.slice(:username) account_params = params.slice(:username)
invite_request_params = { text: params[:reason] } 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, Doorkeeper::AccessToken.create!(application: app,
resource_owner_id: user.id, resource_owner_id: user.id,

View File

@ -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}")

View File

@ -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

View File

@ -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

View File

@ -7,7 +7,7 @@
%strong= account.user_email %strong= account.user_email
= "(@#{account.username})" = "(@#{account.username})"
%br/ %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) = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)

View File

@ -3,13 +3,23 @@
class Scheduler::IpCleanupScheduler class Scheduler::IpCleanupScheduler
include Sidekiq::Worker include Sidekiq::Worker
RETENTION_PERIOD = 1.year IP_RETENTION_PERIOD = 1.year.freeze
sidekiq_options lock: :until_executed, retry: 0 sidekiq_options lock: :until_executed, retry: 0
def perform def perform
time_ago = RETENTION_PERIOD.ago clean_ip_columns!
SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all clean_expired_ip_blocks!
User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil) 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
end end

View File

@ -42,6 +42,10 @@ class Rack::Attack
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1' req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
end 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| throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
req.authenticated_user_id if req.api_request? req.authenticated_user_id if req.api_request?
end end

View File

@ -223,12 +223,14 @@ en:
create_domain_allow: Create Domain Allow create_domain_allow: Create Domain Allow
create_domain_block: Create Domain Block create_domain_block: Create Domain Block
create_email_domain_block: Create E-mail Domain Block create_email_domain_block: Create E-mail Domain Block
create_ip_block: Create IP rule
demote_user: Demote User demote_user: Demote User
destroy_announcement: Delete Announcement destroy_announcement: Delete Announcement
destroy_custom_emoji: Delete Custom Emoji destroy_custom_emoji: Delete Custom Emoji
destroy_domain_allow: Delete Domain Allow destroy_domain_allow: Delete Domain Allow
destroy_domain_block: Delete Domain Block destroy_domain_block: Delete Domain Block
destroy_email_domain_block: Delete e-mail domain block destroy_email_domain_block: Delete e-mail domain block
destroy_ip_block: Delete IP rule
destroy_status: Delete Status destroy_status: Delete Status
disable_2fa_user: Disable 2FA disable_2fa_user: Disable 2FA
disable_custom_emoji: Disable Custom Emoji disable_custom_emoji: Disable Custom Emoji
@ -259,12 +261,14 @@ en:
create_domain_allow: "%{name} allowed federation with domain %{target}" create_domain_allow: "%{name} allowed federation with domain %{target}"
create_domain_block: "%{name} blocked domain %{target}" create_domain_block: "%{name} blocked domain %{target}"
create_email_domain_block: "%{name} blocked e-mail 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}" demote_user: "%{name} demoted user %{target}"
destroy_announcement: "%{name} deleted announcement %{target}" destroy_announcement: "%{name} deleted announcement %{target}"
destroy_custom_emoji: "%{name} destroyed emoji %{target}" destroy_custom_emoji: "%{name} destroyed emoji %{target}"
destroy_domain_allow: "%{name} disallowed federation with domain %{target}" destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
destroy_domain_block: "%{name} unblocked domain %{target}" destroy_domain_block: "%{name} unblocked domain %{target}"
destroy_email_domain_block: "%{name} unblocked e-mail 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}" destroy_status: "%{name} removed status by %{target}"
disable_2fa_user: "%{name} disabled two factor requirement for user %{target}" disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
disable_custom_emoji: "%{name} disabled emoji %{target}" disable_custom_emoji: "%{name} disabled emoji %{target}"
@ -449,6 +453,21 @@ en:
expired: Expired expired: Expired
title: Filter title: Filter
title: Invites 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: pending_accounts:
title: Pending accounts (%{count}) title: Pending accounts (%{count})
relationships: relationships:

View File

@ -65,6 +65,14 @@ en:
data: CSV file exported from another Mastodon server data: CSV file exported from another Mastodon server
invite_request: invite_request:
text: This will help us review your application 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: sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' 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. webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
@ -170,6 +178,13 @@ en:
comment: Comment comment: Comment
invite_request: invite_request:
text: Why do you want to join? 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: notification_emails:
digest: Send digest e-mails digest: Send digest e-mails
favourite: Someone favourited your status favourite: Someone favourited your status

View File

@ -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 :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 :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 :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 end
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s| n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|

View File

@ -283,6 +283,12 @@ Rails.application.routes.draw do
end end
end end
resources :ip_blocks, only: [:index, :new, :create] do
collection do
post :batch
end
end
resources :account_moderation_notes, only: [:create, :destroy] resources :account_moderation_notes, only: [:create, :destroy]
resources :tags, only: [:index, :show, :update] do resources :tags, only: [:index, :show, :update] do

View File

@ -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

View File

@ -0,0 +1,5 @@
class AddSignUpIpToUsers < ActiveRecord::Migration[5.2]
def change
add_column :users, :sign_up_ip, :inet
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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" t.index ["user_id"], name: "index_invites_on_user_id"
end 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| create_table "list_accounts", force: :cascade do |t|
t.bigint "list_id", null: false t.bigint "list_id", null: false
t.bigint "account_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.string "sign_in_token"
t.datetime "sign_in_token_sent_at" t.datetime "sign_in_token_sent_at"
t.string "webauthn_id" t.string "webauthn_id"
t.inet "sign_up_ip"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true 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" t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"

View File

@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli'
require_relative 'mastodon/cache_cli' require_relative 'mastodon/cache_cli'
require_relative 'mastodon/upgrade_cli' require_relative 'mastodon/upgrade_cli'
require_relative 'mastodon/email_domain_blocks_cli' require_relative 'mastodon/email_domain_blocks_cli'
require_relative 'mastodon/ip_blocks_cli'
require_relative 'mastodon/version' require_relative 'mastodon/version'
module Mastodon module Mastodon
@ -57,6 +58,9 @@ module Mastodon
desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks' desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
subcommand 'ip_blocks', Mastodon::IpBlocksCLI
option :dry_run, type: :boolean option :dry_run, type: :boolean
desc 'self-destruct', 'Erase the server from the federation' desc 'self-destruct', 'Erase the server from the federation'
long_desc <<~LONG_DESC long_desc <<~LONG_DESC

View File

@ -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

View File

@ -0,0 +1,6 @@
Fabricator(:ip_block) do
ip ""
severity ""
expires_at "2020-10-08 22:20:37"
comment "MyText"
end

View File

@ -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

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe IpBlock, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end

View File

@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe AppSignUpService, type: :service do RSpec.describe AppSignUpService, type: :service do
let(:app) { Fabricate(:application, scopes: 'read write') } let(:app) { Fabricate(:application, scopes: 'read write') }
let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } } 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 } subject { described_class.new }
@ -10,16 +11,16 @@ RSpec.describe AppSignUpService, type: :service do
it 'returns nil when registrations are closed' do it 'returns nil when registrations are closed' do
tmp = Setting.registrations_mode tmp = Setting.registrations_mode
Setting.registrations_mode = 'none' 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 Setting.registrations_mode = tmp
end end
it 'raises an error when params are missing' do 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 end
it 'creates an unconfirmed user with access token' do 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 expect(access_token).to_not be_nil
user = User.find_by(id: access_token.resource_owner_id) user = User.find_by(id: access_token.resource_owner_id)
expect(user).to_not be_nil expect(user).to_not be_nil
@ -27,13 +28,13 @@ RSpec.describe AppSignUpService, type: :service do
end end
it 'creates access token with the app\'s scopes' do 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).to_not be_nil
expect(access_token.scopes.to_s).to eq 'read write' expect(access_token.scopes.to_s).to eq 'read write'
end end
it 'creates an account' do 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 expect(access_token).to_not be_nil
user = User.find_by(id: access_token.resource_owner_id) user = User.find_by(id: access_token.resource_owner_id)
expect(user).to_not be_nil expect(user).to_not be_nil
@ -42,7 +43,7 @@ RSpec.describe AppSignUpService, type: :service do
end end
it 'creates an account with invite request text' do 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 expect(access_token).to_not be_nil
user = User.find_by(id: access_token.resource_owner_id) user = User.find_by(id: access_token.resource_owner_id)
expect(user).to_not be_nil expect(user).to_not be_nil