Add IP-based rules (#14963)
parent
dc52a778e1
commit
5e1364c448
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,15 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class IpBlockPolicy < ApplicationPolicy |
||||||
|
def index? |
||||||
|
admin? |
||||||
|
end |
||||||
|
|
||||||
|
def create? |
||||||
|
admin? |
||||||
|
end |
||||||
|
|
||||||
|
def destroy? |
||||||
|
admin? |
||||||
|
end |
||||||
|
end |
@ -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}") |
@ -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 |
||||||
|
|
@ -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 |
@ -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 |
@ -0,0 +1,5 @@ |
|||||||
|
class AddSignUpIpToUsers < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
add_column :users, :sign_up_ip, :inet |
||||||
|
end |
||||||
|
end |
@ -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 |
@ -0,0 +1,6 @@ |
|||||||
|
Fabricator(:ip_block) do |
||||||
|
ip "" |
||||||
|
severity "" |
||||||
|
expires_at "2020-10-08 22:20:37" |
||||||
|
comment "MyText" |
||||||
|
end |
@ -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 |
@ -0,0 +1,5 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe IpBlock, type: :model do |
||||||
|
pending "add some examples to (or delete) #{__FILE__}" |
||||||
|
end |
Loading…
Reference in new issue