diff --git a/CHANGELOG.md b/CHANGELOG.md index e114486..afce862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. ### Added +- Shorthand for blocking an IP address `Rack::Attack.blocklist_ip("1.2.3.4")` +- Shorthand for blocking an IP subnet `Rack::Attack.blocklist_ip("1.2.0.0/16")` +- Shorthand for safelisting an IP address `Rack::Attack.safelist_ip("5.6.7.8")` +- Shorthand for safelisting an IP subnet `Rack::Attack.safelist_ip("5.6.0.0/16")` - Throw helpful error message when using `allow2ban` but cache store is misconfigured ([#315](https://github.com/kickstarter/rack-attack/issues/315)) - Throw helpful error message when using `fail2ban` but cache store is misconfigured ([#315](https://github.com/kickstarter/rack-attack/issues/315)) diff --git a/lib/rack/attack.rb b/lib/rack/attack.rb index dd151fd..35b2e35 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -2,6 +2,7 @@ require 'rack' require 'forwardable' require 'rack/attack/path_normalizer' require 'rack/attack/request' +require "ipaddr" class Rack::Attack class MisconfiguredStoreError < StandardError; end @@ -37,6 +38,18 @@ class Rack::Attack self.blocklists[name] = Blocklist.new(name, block) end + def blocklist_ip(ip) + @ip_blocklists ||= [] + ip_blocklist_proc = lambda { |request| IPAddr.new(ip).include?(IPAddr.new(request.ip)) } + @ip_blocklists << Blocklist.new(nil, ip_blocklist_proc) + end + + def safelist_ip(ip) + @ip_safelists ||= [] + ip_safelist_proc = lambda { |request| IPAddr.new(ip).include?(IPAddr.new(request.ip)) } + @ip_safelists << Safelist.new(nil, ip_safelist_proc) + end + def blacklist(name, &block) warn "[DEPRECATION] 'Rack::Attack.blacklist' is deprecated. Please use 'blocklist' instead." blocklist(name, &block) @@ -66,9 +79,8 @@ class Rack::Attack end def safelisted?(req) - safelists.any? do |name, safelist| - safelist[req] - end + ip_safelists.any? { |safelist| safelist.match?(req) } || + safelists.any? { |_name, safelist| safelist.match?(req) } end def whitelisted?(req) @@ -77,9 +89,8 @@ class Rack::Attack end def blocklisted?(req) - blocklists.any? do |name, blocklist| - blocklist[req] - end + ip_blocklists.any? { |blocklist| blocklist.match?(req) } || + blocklists.any? { |_name, blocklist| blocklist.match?(req) } end def blacklisted?(req) @@ -109,6 +120,8 @@ class Rack::Attack def clear! @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {} + @ip_blocklists = [] + @ip_safelists = [] end def blacklisted_response=(res) @@ -121,6 +134,15 @@ class Rack::Attack blocklisted_response end + private + + def ip_blocklists + @ip_blocklists ||= [] + end + + def ip_safelists + @ip_safelists ||= [] + end end # Set defaults diff --git a/lib/rack/attack/check.rb b/lib/rack/attack/check.rb index 21451de..8ae9ba1 100644 --- a/lib/rack/attack/check.rb +++ b/lib/rack/attack/check.rb @@ -17,6 +17,7 @@ module Rack } end + alias_method :match?, :[] end end end diff --git a/spec/acceptance/blocking_ip_spec.rb b/spec/acceptance/blocking_ip_spec.rb new file mode 100644 index 0000000..72c599c --- /dev/null +++ b/spec/acceptance/blocking_ip_spec.rb @@ -0,0 +1,38 @@ +require_relative "../spec_helper" + +describe "Blocking an IP" do + before do + Rack::Attack.blocklist_ip("1.2.3.4") + end + + it "forbids request if IP matches" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + + it "succeeds if IP doesn't match" do + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + end + + it "notifies when the request is blocked" do + notified = false + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request| + notified = true + notification_type = request.env["rack.attack.match_type"] + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + refute notified + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert notified + assert_equal :blocklist, notification_type + end +end diff --git a/spec/acceptance/blocking_subnet_spec.rb b/spec/acceptance/blocking_subnet_spec.rb new file mode 100644 index 0000000..13b4f91 --- /dev/null +++ b/spec/acceptance/blocking_subnet_spec.rb @@ -0,0 +1,44 @@ +require_relative "../spec_helper" + +describe "Blocking an IP subnet" do + before do + Rack::Attack.blocklist_ip("1.2.3.4/31") + end + + it "forbids request if IP is inside the subnet" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + + it "forbids request for another IP in the subnet" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.5" + + assert_equal 403, last_response.status + end + + it "succeeds if IP is outside the subnet" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.6" + + assert_equal 200, last_response.status + end + + it "notifies when the request is blocked" do + notified = false + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request| + notified = true + notification_type = request.env["rack.attack.match_type"] + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + refute notified + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert notified + assert_equal :blocklist, notification_type + end +end diff --git a/spec/acceptance/safelisting_ip_spec.rb b/spec/acceptance/safelisting_ip_spec.rb new file mode 100644 index 0000000..38faf72 --- /dev/null +++ b/spec/acceptance/safelisting_ip_spec.rb @@ -0,0 +1,49 @@ +require_relative "../spec_helper" + +describe "Safelist an IP" do + before do + Rack::Attack.blocklist("admin") do |request| + request.path == "/admin" + end + + Rack::Attack.safelist_ip("5.6.7.8") + end + + it "forbids request if blocklist condition is true and safelist is false" do + get "/admin", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + + it "succeeds if blocklist condition is false and safelist is false" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + + it "succeeds request if blocklist condition is false and safelist is true" do + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + end + + it "succeeds request if both blocklist and safelist conditions are true" do + get "/admin", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + end + + it "notifies when the request is safe" do + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request| + notification_type = request.env["rack.attack.match_type"] + end + + get "/admin", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + assert_equal :safelist, notification_type + end +end + diff --git a/spec/acceptance/safelisting_subnet_spec.rb b/spec/acceptance/safelisting_subnet_spec.rb new file mode 100644 index 0000000..920f265 --- /dev/null +++ b/spec/acceptance/safelisting_subnet_spec.rb @@ -0,0 +1,48 @@ +require_relative "../spec_helper" + +describe "Safelisting an IP subnet" do + before do + Rack::Attack.blocklist("admin") do |request| + request.path == "/admin" + end + + Rack::Attack.safelist_ip("5.6.0.0/16") + end + + it "forbids request if blocklist condition is true and safelist is false" do + get "/admin", {}, "REMOTE_ADDR" => "5.7.0.0" + + assert_equal 403, last_response.status + end + + it "succeeds if blocklist condition is false and safelist is false" do + get "/", {}, "REMOTE_ADDR" => "5.7.0.0" + + assert_equal 200, last_response.status + end + + it "succeeds request if blocklist condition is false and safelist is true" do + get "/", {}, "REMOTE_ADDR" => "5.6.0.0" + + assert_equal 200, last_response.status + end + + it "succeeds request if both blocklist and safelist conditions are true" do + get "/admin", {}, "REMOTE_ADDR" => "5.6.255.255" + + assert_equal 200, last_response.status + end + + it "notifies when the request is safe" do + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, request| + notification_type = request.env["rack.attack.match_type"] + end + + get "/admin", {}, "REMOTE_ADDR" => "5.6.0.0" + + assert_equal 200, last_response.status + assert_equal :safelist, notification_type + end +end