| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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? | |||
| @@ -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 | |||
| @@ -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? | |||
| @@ -0,0 +1,15 @@ | |||
| # frozen_string_literal: true | |||
| class IpBlockPolicy < ApplicationPolicy | |||
| def index? | |||
| admin? | |||
| end | |||
| def create? | |||
| admin? | |||
| end | |||
| def destroy? | |||
| admin? | |||
| end | |||
| end | |||
| @@ -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, | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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: | |||
| @@ -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 | |||
| @@ -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| | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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" | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||