diff --git a/CHANGELOG b/CHANGELOG index 2e0eee52a59..a17f2bfef45 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -71,6 +71,7 @@ v 8.4.0 (unreleased) - Expose button to CI Lint tool on project builds page - Fix: Creator should be added as a master of the project on creation - Added X-GitLab-... headers to emails from CI and Email On Push services (Anton Baklanov) + - Add IP check against DNSBLs at account sign-up v 8.3.4 - Use gitlab-workhorse 0.5.4 (fixes API routing bug) diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 91f7d78bd73..6df8e8e4cd8 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -74,6 +74,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :metrics_timeout, :metrics_method_call_threshold, :metrics_sample_interval, + :ip_blocking_enabled, + :dnsbl_servers_list, :recaptcha_enabled, :recaptcha_site_key, :recaptcha_private_key, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index c48175a4c5a..5efdd613e79 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,6 +8,11 @@ class RegistrationsController < Devise::RegistrationsController def create if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha + if Gitlab::IpCheck.new(request.remote_ip).spam? + flash[:alert] = 'Could not create an account. This IP is listed for spam.' + return render action: 'new' + end + super else flash[:alert] = "There was an error with the reCAPTCHA code below. Please re-enter the code." diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6c6c2468374..db208884aa6 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -41,6 +41,8 @@ # recaptcha_site_key :string # recaptcha_private_key :string # metrics_port :integer default(8089) +# ip_blocking_enabled :boolean default(FALSE) +# dns_blacklist_threshold :float default(0.33) # class ApplicationSetting < ActiveRecord::Base diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 83f6814d822..4f915818d8f 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -212,6 +212,22 @@ %fieldset %legend Spam and Anti-bot Protection + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :ip_blocking_enabled do + = f.check_box :ip_blocking_enabled + Enable IP check against blacklist at sign-up + .help-block Helps preventing accounts creation from 'known spam sources' + + .form-group + = f.label :dnsbl_servers_list, class: 'control-label col-sm-2' do + DNSBL servers list + .col-sm-10 + = f.text_field :dnsbl_servers_list, class: 'form-control' + .help-block + Please enter DNSBL servers separated with comma + .form-group .col-sm-offset-2.col-sm-10 .checkbox diff --git a/db/migrate/20160118232755_add_ip_blocking_settings_to_application_settings.rb b/db/migrate/20160118232755_add_ip_blocking_settings_to_application_settings.rb new file mode 100644 index 00000000000..26606b10b54 --- /dev/null +++ b/db/migrate/20160118232755_add_ip_blocking_settings_to_application_settings.rb @@ -0,0 +1,6 @@ +class AddIpBlockingSettingsToApplicationSettings < ActiveRecord::Migration + def change + add_column :application_settings, :ip_blocking_enabled, :boolean, default: false + add_column :application_settings, :dnsbl_servers_list, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index dc7cb9f667f..1457259ebaf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160119145451) do +ActiveRecord::Schema.define(version: 20160120130905) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -62,6 +62,8 @@ ActiveRecord::Schema.define(version: 20160119145451) do t.string "recaptcha_private_key" t.integer "metrics_port", default: 8089 t.integer "metrics_sample_interval", default: 15 + t.boolean "ip_blocking_enabled", default: false + t.text "dnsbl_servers_list" end create_table "audit_events", force: :cascade do |t| diff --git a/lib/dnsxl_check.rb b/lib/dnsxl_check.rb new file mode 100644 index 00000000000..1e506b2d9cb --- /dev/null +++ b/lib/dnsxl_check.rb @@ -0,0 +1,105 @@ +require 'resolv' + +class DNSXLCheck + + class Resolver + def self.search(query) + begin + Resolv.getaddress(query) + true + rescue Resolv::ResolvError + false + end + end + end + + IP_REGEXP = /\A(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\z/ + DEFAULT_THRESHOLD = 0.33 + + def self.create_from_list(list) + dnsxl_check = DNSXLCheck.new + + list.each do |entry| + dnsxl_check.add_list(entry.domain, entry.weight) + end + + dnsxl_check + end + + def test(ip) + if use_threshold? + test_with_threshold(ip) + else + test_strict(ip) + end + end + + def test_with_threshold(ip) + return false if lists.empty? + + search(ip) + final_score >= threshold + end + + def test_strict(ip) + return false if lists.empty? + + search(ip) + @score > 0 + end + + def use_threshold=(value) + @use_threshold = value == true + end + + def use_threshold? + @use_threshold &&= true + end + + def threshold=(threshold) + raise ArgumentError, "'threshold' value must be grather than 0 and less than or equal to 1" unless threshold > 0 && threshold <= 1 + @threshold = threshold + end + + def threshold + @threshold ||= DEFAULT_THRESHOLD + end + + def add_list(domain, weight) + @lists ||= [] + @lists << { domain: domain, weight: weight } + end + + def lists + @lists ||= [] + end + + private + + def search(ip) + raise ArgumentError, "'ip' value must be in #{IP_REGEXP} format" unless ip.match(IP_REGEXP) + + @score = 0 + + reversed = reverse_ip(ip) + search_in_rbls(reversed) + end + + def reverse_ip(ip) + ip.split('.').reverse.join('.') + end + + def search_in_rbls(reversed_ip) + lists.each do |rbl| + query = "#{reversed_ip}.#{rbl[:domain]}" + @score += rbl[:weight] if Resolver.search(query) + end + end + + def final_score + weights = lists.map{ |rbl| rbl[:weight] }.reduce(:+).to_i + return 0 if weights == 0 + + (@score.to_f / weights.to_f).round(2) + end +end diff --git a/lib/gitlab/ip_check.rb b/lib/gitlab/ip_check.rb new file mode 100644 index 00000000000..f2e9b50d225 --- /dev/null +++ b/lib/gitlab/ip_check.rb @@ -0,0 +1,34 @@ +module Gitlab + class IpCheck + + def initialize(ip) + @ip = ip + + application_settings = ApplicationSetting.current + @ip_blocking_enabled = application_settings.ip_blocking_enabled + @dnsbl_servers_list = application_settings.dnsbl_servers_list + end + + def spam? + @ip_blocking_enabled && blacklisted? + end + + private + + def blacklisted? + on_dns_blacklist? + end + + def on_dns_blacklist? + dnsbl_check = DNSXLCheck.new + prepare_dnsbl_list(dnsbl_check) + dnsbl_check.test(@ip) + end + + def prepare_dnsbl_list(dnsbl_check) + @dnsbl_servers_list.split(',').map(&:strip).reject(&:empty?).each do |domain| + dnsbl_check.add_list(domain, 1) + end + end + end +end diff --git a/spec/lib/dnsxl_check_spec.rb b/spec/lib/dnsxl_check_spec.rb new file mode 100644 index 00000000000..a35a1be0c90 --- /dev/null +++ b/spec/lib/dnsxl_check_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' +require 'ostruct' + +describe 'DNSXLCheck', lib: true, no_db: true do + let(:spam_ip) { '127.0.0.2' } + let(:no_spam_ip) { '127.0.0.3' } + let(:invalid_ip) { 'a.b.c.d' } + let!(:dnsxl_check) { DNSXLCheck.create_from_list([OpenStruct.new({ domain: 'test', weight: 1 })]) } + + before(:context) do + class DNSXLCheck::Resolver + class << self + alias_method :old_search, :search + def search(query) + return false if query.match(/always\.failing\.domain\z/) + return true if query.match(/\A2\.0\.0\.127\./) + return false if query.match(/\A3\.0\.0\.127\./) + end + end + end + end + + describe '#test' do + before do + dnsxl_check.threshold = 0.75 + dnsxl_check.add_list('always.failing.domain', 1) + end + + context 'when threshold is used' do + before { dnsxl_check.use_threshold= true } + + it { expect(dnsxl_check.test(spam_ip)).to be_falsey } + end + + context 'when threshold is not used' do + before { dnsxl_check.use_threshold= false } + + it { expect(dnsxl_check.test(spam_ip)).to be_truthy } + end + end + + describe '#test_with_threshold' do + it { expect{ dnsxl_check.test_with_threshold(invalid_ip) }.to raise_error(ArgumentError) } + + it { expect(dnsxl_check.test_with_threshold(spam_ip)).to be_truthy } + it { expect(dnsxl_check.test_with_threshold(no_spam_ip)).to be_falsey } + end + + describe '#test_strict' do + before do + dnsxl_check.threshold = 1 + dnsxl_check.add_list('always.failing.domain', 1) + end + + it { expect{ dnsxl_check.test_strict(invalid_ip) }.to raise_error(ArgumentError) } + + it { expect(dnsxl_check.test_with_threshold(spam_ip)).to be_falsey } + it { expect(dnsxl_check.test_with_threshold(no_spam_ip)).to be_falsey } + it { expect(dnsxl_check.test_strict(spam_ip)).to be_truthy } + it { expect(dnsxl_check.test_strict(no_spam_ip)).to be_falsey } + end + + describe '#threshold=' do + it { expect{ dnsxl_check.threshold = 0 }.to raise_error(ArgumentError) } + it { expect{ dnsxl_check.threshold = 1.1 }.to raise_error(ArgumentError) } + it { expect{ dnsxl_check.threshold = 0.5 }.not_to raise_error } + end +end