From 6a5cd3ca41f61a188b221e6436e9a0d06c0484f2 Mon Sep 17 00:00:00 2001
From: Robert Speicher <rspeicher@gmail.com>
Date: Wed, 20 Jan 2016 14:24:20 -0500
Subject: [PATCH] Merge branch 'feature/check-against-rbl-only' into 'master'

Split from !2455

References #9092

See merge request !2515
---
 CHANGELOG                                     |   1 +
 .../admin/application_settings_controller.rb  |   2 +
 app/controllers/registrations_controller.rb   |   5 +
 app/models/application_setting.rb             |   2 +
 .../application_settings/_form.html.haml      |  16 +++
 ...ocking_settings_to_application_settings.rb |   6 +
 db/schema.rb                                  |   4 +-
 lib/dnsxl_check.rb                            | 105 ++++++++++++++++++
 lib/gitlab/ip_check.rb                        |  34 ++++++
 spec/lib/dnsxl_check_spec.rb                  |  68 ++++++++++++
 10 files changed, 242 insertions(+), 1 deletion(-)
 create mode 100644 db/migrate/20160118232755_add_ip_blocking_settings_to_application_settings.rb
 create mode 100644 lib/dnsxl_check.rb
 create mode 100644 lib/gitlab/ip_check.rb
 create mode 100644 spec/lib/dnsxl_check_spec.rb

diff --git a/CHANGELOG b/CHANGELOG
index 17516b43181..66a94334c17 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -69,6 +69,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 9943745208e..094eef28a43 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 59563b8823c..2f3487b53ac 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -43,6 +43,8 @@
 #  metrics_port                      :integer          default(8089)
 #  sentry_enabled                    :boolean          default(FALSE)
 #  sentry_dsn                        :string
+#  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 35e4dd761ab..c4020c8273b 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 a9559ae0619..b52f3a2d094 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"
@@ -64,6 +64,8 @@ ActiveRecord::Schema.define(version: 20160119145451) do
     t.integer  "metrics_sample_interval",           default: 15
     t.boolean  "sentry_enabled",                    default: false
     t.string   "sentry_dsn"
+    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
-- 
2.30.9