diff --git a/.gitignore b/.gitignore index 9bb08f10..8624f933 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ bin .bundle *.gem *.gemfile.lock +.ruby-version +.ruby-gemset diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2a12eb..b5056899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ## v4.2.0 - 26 Oct 2014 - Throttle's `period` argument now takes a proc as well as a number (thanks @gsamokovarov) - - Invoke the `#call` method on `blacklist_response` and `throttle_response` instead of `#[]`, as per the Rack spec. (thanks @gsamokovarov) + - Invoke the `#call` method on `blocklist_response` and `throttle_response` instead of `#[]`, as per the Rack spec. (thanks @gsamokovarov) ## v4.1.1 - 11 Sept 2014 - Fix a race condition in throttles that could allow more requests than intended. @@ -35,7 +35,7 @@ ## v4.1.0 - 22 May 2014 - Tracks take an optional limit and period to only notify once a threshold is reached (similar to throttles). Thanks @chiliburger! - - Default throttled & blacklist responses have Content-Type: text/plain + - Default throttled & blocklist responses have Content-Type: text/plain - Rack::Attack.clear! resets tracks ## v4.0.1 - 14 May 2014 @@ -49,7 +49,7 @@ * Test more dalli versions. ## v3.0.0 - 15 March 2014 - * Change default blacklisted response to 403 Forbidden (thanks @carpodaster). + * Change default blocklisted response to 403 Forbidden (thanks @carpodaster). * Fail gracefully when Redis store is not available; rescue exeption and don't throttle request. (thanks @wkimeria) * TravisCI runs integration tests. @@ -62,7 +62,7 @@ ## v2.2.1 - 13 August 2013 * Add license to gemspec * Support ruby version 1.9.2 - * Change default blacklisted response code from 503 to 401; throttled response + * Change default blocklisted response code from 503 to 401; throttled response from 503 to 429. ## v2.2.0 - 20 June 2013 diff --git a/README.md b/README.md index f55954f4..6480b87e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ *Rack middleware for blocking & throttling abusive requests* Rack::Attack is a rack middleware to protect your web app from bad clients. -It allows *whitelisting*, *blacklisting*, *throttling*, and *tracking* based on arbitrary properties of the request. +It allows *safelisting*, *blocklisting*, *throttling*, and *tracking* based on arbitrary properties of the request. Throttle and fail2ban state is stored in a configurable cache (e.g. `Rails.cache`), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)). @@ -53,14 +53,14 @@ Optionally configure the cache store for throttling or fail2ban filtering: Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache ``` -Note that `Rack::Attack.cache` is only used for throttling and fail2ban filtering; not blacklisting & whitelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). +Note that `Rack::Attack.cache` is only used for throttling and fail2ban filtering; not blocklisting & safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). ## How it works -The Rack::Attack middleware compares each request against *whitelists*, *blacklists*, *throttles*, and *tracks* that you define. There are none by default. +The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. - * If the request matches any **whitelist**, it is allowed. - * Otherwise, if the request matches any **blacklist**, it is blocked. + * If the request matches any **safelist**, it is allowed. + * Otherwise, if the request matches any **blocklist**, it is blocked. * Otherwise, if the request matches any **throttle**, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked. * Otherwise, all **tracks** are checked, and the request is allowed. @@ -70,10 +70,10 @@ The algorithm is actually more concise in code: See [Rack::Attack.call](https:// def call(env) req = Rack::Attack::Request.new(env) - if whitelisted?(req) + if safelisted?(req) @app.call(env) - elsif blacklisted?(req) - self.class.blacklisted_response.call(env) + elsif blocklisted?(req) + self.class.blocklisted_response.call(env) elsif throttled?(req) self.class.throttled_response.call(env) else @@ -93,47 +93,47 @@ can cleanly monkey patch helper methods onto the ## Usage -Define whitelists, blacklists, throttles, and tracks as blocks that return truthy values if matched, falsy otherwise. In a Rails app +Define safelists, blocklists, throttles, and tracks as blocks that return truthy values if matched, falsy otherwise. In a Rails app these go in an initializer in `config/initializers/`. A [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request) object is passed to the block (named 'req' in the examples). -### Whitelists +### safelists ```ruby # Always allow requests from localhost -# (blacklist & throttles are skipped) -Rack::Attack.whitelist('allow from localhost') do |req| +# (blocklist & throttles are skipped) +Rack::Attack.safelist('allow from localhost') do |req| # Requests are allowed if the return value is truthy '127.0.0.1' == req.ip || '::1' == req.ip end ``` -### Blacklists +### blocklists ```ruby # Block requests from 1.2.3.4 -Rack::Attack.blacklist('block 1.2.3.4') do |req| +Rack::Attack.blocklist('block 1.2.3.4') do |req| # Requests are blocked if the return value is truthy '1.2.3.4' == req.ip end # Block logins from a bad user agent -Rack::Attack.blacklist('block bad UA logins') do |req| +Rack::Attack.blocklist('block bad UA logins') do |req| req.path == '/login' && req.post? && req.user_agent == 'BadUA' end ``` #### Fail2Ban -`Fail2Ban.filter` can be used within a blacklist to block all requests from misbehaving clients. +`Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients. This pattern is inspired by [fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page). See the [fail2ban documentation](http://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on -how the parameters work. For multiple filters, be sure to put each filter in a separate blacklist and use a unique discriminator for each fail2ban filter. +how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter. ```ruby # Block suspicious requests for '/etc/password' or wordpress specific paths. # After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes. -Rack::Attack.blacklist('fail2ban pentesters') do |req| +Rack::Attack.blocklist('fail2ban pentesters') do |req| # `filter` returns truthy value if request fails, or if it's from a previously banned IP # so the request is blocked Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", :maxretry => 3, :findtime => 10.minutes, :bantime => 5.minutes) do @@ -147,7 +147,7 @@ Rack::Attack.blacklist('fail2ban pentesters') do |req| end ``` -Note that `Fail2Ban` filters are not automatically scoped to the blacklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`. +Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`. #### Allow2Ban `Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving @@ -155,7 +155,7 @@ clients until such time as they reach maxretry at which they are cut off as per ```ruby # Lockout IP addresses that are hammering your login page. # After 20 requests in 1 minute, block all requests from that IP for 1 hour. -Rack::Attack.blacklist('allow2ban login scrapers') do |req| +Rack::Attack.blocklist('allow2ban login scrapers') do |req| # `filter` returns false value if request is to your login page (but still # increments the count) so request below the limit are not blocked until # they hit the limit. At that point, filter will return true and block. @@ -220,12 +220,12 @@ end ## Responses -Customize the response of blacklisted and throttled requests using an object that adheres to the [Rack app interface](http://rack.rubyforge.org/doc/SPEC.html). +Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://rack.rubyforge.org/doc/SPEC.html). ```ruby -Rack::Attack.blacklisted_response = lambda do |env| +Rack::Attack.blocklisted_response = lambda do |env| # Using 503 because it may make attacker think that they have successfully - # DOSed the site. Rack::Attack returns 403 for blacklists by default + # DOSed the site. Rack::Attack returns 403 for blocklists by default [ 503, {}, ['Blocked']] end @@ -274,7 +274,7 @@ but it depends on how many checks you've configured, and how long they take. Throttles usually require a network roundtrip to your cache server(s), so try to keep the number of throttle checks per request low. -If a request is blacklisted or throttled, the response is a very simple Rack response. +If a request is blocklisted or throttled, the response is a very simple Rack response. A single typical ruby web server thread can block several hundred requests per second. Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone). diff --git a/examples/rack_attack.rb b/examples/rack_attack.rb index ace87854..5cb8cc88 100644 --- a/examples/rack_attack.rb +++ b/examples/rack_attack.rb @@ -15,12 +15,12 @@ req.post? && req.path == "/login" && req.params['email'] end -# Blacklist bad IPs from accessing admin pages -Rack::Attack.blacklist "bad_ips from logging in" do |req| +# blocklist bad IPs from accessing admin pages +Rack::Attack.blocklist "bad_ips from logging in" do |req| req.path =~ /^\/admin/ && bad_ips.include?(req.ip) end -# Whitelist a User-Agent -Rack::Attack.whitelist 'internal user agent' do |req| +# safelist a User-Agent +Rack::Attack.safelist 'internal user agent' do |req| req.user_agent == 'InternalUserAgent' end diff --git a/lib/rack/attack.rb b/lib/rack/attack.rb index 672f2335..744f77a0 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -6,8 +6,8 @@ class Rack::Attack autoload :PathNormalizer, 'rack/attack/path_normalizer' autoload :Check, 'rack/attack/check' autoload :Throttle, 'rack/attack/throttle' - autoload :Whitelist, 'rack/attack/whitelist' - autoload :Blacklist, 'rack/attack/blacklist' + autoload :Safelist, 'rack/attack/safelist' + autoload :Blocklist, 'rack/attack/blocklist' autoload :Track, 'rack/attack/track' autoload :StoreProxy, 'rack/attack/store_proxy' autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy' @@ -19,14 +19,24 @@ class Rack::Attack class << self - attr_accessor :notifier, :blacklisted_response, :throttled_response + attr_accessor :notifier, :blocklisted_response, :throttled_response + def safelist(name, &block) + self.safelists[name] = Safelist.new(name, block) + end + def whitelist(name, &block) - self.whitelists[name] = Whitelist.new(name, block) + warn "[DEPRECATION] 'whitelist' is deprecated. Please use 'safelist' instead." + safelist(name, &block) + end + + def blocklist(name, &block) + self.blocklists[name] = Blocklist.new(name, block) end def blacklist(name, &block) - self.blacklists[name] = Blacklist.new(name, block) + warn "[DEPRECATION] 'blacklist' is deprecated. Please use 'blocklist' instead." + blocklist(name, &block) end def throttle(name, options, &block) @@ -37,23 +47,43 @@ def track(name, options = {}, &block) self.tracks[name] = Track.new(name, options, block) end - def whitelists; @whitelists ||= {}; end - def blacklists; @blacklists ||= {}; end + def safelists; @safelists ||= {}; end + def blocklists; @blocklists ||= {}; end def throttles; @throttles ||= {}; end def tracks; @tracks ||= {}; end - def whitelisted?(req) - whitelists.any? do |name, whitelist| - whitelist[req] + def whitelists + warn "[DEPRECATION] 'whitelists' is deprecated. Please use 'safelists' instead." + safelists + end + + def blacklists + warn "[DEPRECATION] 'blacklists' is deprecated. Please use 'blocklists' instead." + blocklists + end + + def safelisted?(req) + safelists.any? do |name, safelist| + safelist[req] end end - def blacklisted?(req) - blacklists.any? do |name, blacklist| - blacklist[req] + def whitelisted? + warn "[DEPRECATION] 'whitelisted?' is deprecated. Please use 'safelisted?' instead." + safelisted? + end + + def blocklisted?(req) + blocklists.any? do |name, blocklist| + blocklist[req] end end + def blacklisted? + warn "[DEPRECATION] 'blacklisted?' is deprecated. Please use 'blocklisted?' instead." + blocklisted? + end + def throttled?(req) throttles.any? do |name, throttle| throttle[req] @@ -75,14 +105,14 @@ def cache end def clear! - @whitelists, @blacklists, @throttles, @tracks = {}, {}, {}, {} + @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {} end end # Set defaults @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) - @blacklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] } + @blocklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] } @throttled_response = lambda {|env| retry_after = (env['rack.attack.match_data'] || {})[:period] [429, {'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s}, ["Retry later\n"]] @@ -96,10 +126,10 @@ def call(env) env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) req = Rack::Attack::Request.new(env) - if whitelisted?(req) + if safelisted?(req) @app.call(env) - elsif blacklisted?(req) - self.class.blacklisted_response.call(env) + elsif blocklisted?(req) + self.class.blocklisted_response.call(env) elsif throttled?(req) self.class.throttled_response.call(env) else @@ -109,8 +139,8 @@ def call(env) end extend Forwardable - def_delegators self, :whitelisted?, - :blacklisted?, + def_delegators self, :safelisted?, + :blocklisted?, :throttled?, :tracked? end diff --git a/lib/rack/attack/whitelist.rb b/lib/rack/attack/blocklist.rb similarity index 65% rename from lib/rack/attack/whitelist.rb rename to lib/rack/attack/blocklist.rb index 604268eb..b61a29fe 100644 --- a/lib/rack/attack/whitelist.rb +++ b/lib/rack/attack/blocklist.rb @@ -1,9 +1,9 @@ module Rack class Attack - class Whitelist < Check + class Blocklist < Check def initialize(name, block) super - @type = :whitelist + @type = :blocklist end end diff --git a/lib/rack/attack/fail2ban.rb b/lib/rack/attack/fail2ban.rb index c83489a6..76dc59fa 100644 --- a/lib/rack/attack/fail2ban.rb +++ b/lib/rack/attack/fail2ban.rb @@ -8,7 +8,7 @@ def filter(discriminator, options) maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option" if banned?(discriminator) - # Return true for blacklist + # Return true for blocklist true elsif yield fail!(discriminator, bantime, findtime, maxretry) diff --git a/lib/rack/attack/request.rb b/lib/rack/attack/request.rb index 0dc9681a..ee05f89a 100644 --- a/lib/rack/attack/request.rb +++ b/lib/rack/attack/request.rb @@ -9,7 +9,7 @@ # end # end # -# Rack::Attack.whitelist("localhost") {|req| req.localhost? } +# Rack::Attack.safelist("localhost") {|req| req.localhost? } # module Rack class Attack diff --git a/lib/rack/attack/blacklist.rb b/lib/rack/attack/safelist.rb similarity index 65% rename from lib/rack/attack/blacklist.rb rename to lib/rack/attack/safelist.rb index c3a83419..748d422b 100644 --- a/lib/rack/attack/blacklist.rb +++ b/lib/rack/attack/safelist.rb @@ -1,12 +1,11 @@ module Rack class Attack - class Blacklist < Check + class Safelist < Check def initialize(name, block) super - @type = :blacklist + @type = :safelist end end end end - diff --git a/spec/allow2ban_spec.rb b/spec/allow2ban_spec.rb index c6a6836f..3e3cf479 100644 --- a/spec/allow2ban_spec.rb +++ b/spec/allow2ban_spec.rb @@ -7,7 +7,7 @@ @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2} - Rack::Attack.blacklist('pentest') do |req| + Rack::Attack.blocklist('pentest') do |req| Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/} end end diff --git a/spec/fail2ban_spec.rb b/spec/fail2ban_spec.rb index e131cf16..0f24524a 100644 --- a/spec/fail2ban_spec.rb +++ b/spec/fail2ban_spec.rb @@ -7,7 +7,7 @@ @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2} - Rack::Attack.blacklist('pentest') do |req| + Rack::Attack.blocklist('pentest') do |req| Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/} end end diff --git a/spec/rack_attack_request_spec.rb b/spec/rack_attack_request_spec.rb index ca3eadd9..cc617aed 100644 --- a/spec/rack_attack_request_spec.rb +++ b/spec/rack_attack_request_spec.rb @@ -9,7 +9,7 @@ def remote_ip end end - Rack::Attack.whitelist('valid IP') do |req| + Rack::Attack.safelist('valid IP') do |req| req.remote_ip == "127.0.0.1" end end diff --git a/spec/rack_attack_spec.rb b/spec/rack_attack_spec.rb index 3084ffb1..6d29c9c4 100644 --- a/spec/rack_attack_spec.rb +++ b/spec/rack_attack_spec.rb @@ -5,7 +5,7 @@ describe 'normalizing paths' do before do - Rack::Attack.blacklist("banned_path") {|req| req.path == '/foo' } + Rack::Attack.blocklist("banned_path") {|req| req.path == '/foo' } end it 'blocks requests with trailing slash' do @@ -14,47 +14,62 @@ end end - describe 'blacklist' do + describe 'blocklist' do before do @bad_ip = '1.2.3.4' - Rack::Attack.blacklist("ip #{@bad_ip}") {|req| req.ip == @bad_ip } + Rack::Attack.blocklist("ip #{@bad_ip}") {|req| req.ip == @bad_ip } end - it('has a blacklist') { - Rack::Attack.blacklists.key?("ip #{@bad_ip}").must_equal true + it('has a blocklist') { + Rack::Attack.blocklists.key?("ip #{@bad_ip}").must_equal true + } + + it('has a blacklist with a deprication warning') { + stdout, stderror = capture_io do + Rack::Attack.blacklists.key?("ip #{@bad_ip}").must_equal true + end + assert_match "[DEPRECATION] 'blacklists' is deprecated. Please use 'blocklists' instead.", stderror } describe "a bad request" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip } - it "should return a blacklist response" do + it "should return a blocklist response" do get '/', {}, 'REMOTE_ADDR' => @bad_ip last_response.status.must_equal 403 last_response.body.must_equal "Forbidden\n" end it "should tag the env" do last_request.env['rack.attack.matched'].must_equal "ip #{@bad_ip}" - last_request.env['rack.attack.match_type'].must_equal :blacklist + last_request.env['rack.attack.match_type'].must_equal :blocklist end allow_ok_requests end - describe "and whitelist" do + describe "and safelist" do before do @good_ua = 'GoodUA' - Rack::Attack.whitelist("good ua") {|req| req.user_agent == @good_ua } + Rack::Attack.safelist("good ua") {|req| req.user_agent == @good_ua } end - it('has a whitelist'){ Rack::Attack.whitelists.key?("good ua") } - describe "with a request match both whitelist & blacklist" do + it('has a safelist'){ Rack::Attack.safelists.key?("good ua") } + + it('has a whitelist with a deprication warning') { + stdout, stderror = capture_io do + Rack::Attack.whitelists.key?("good ua") + end + assert_match "[DEPRECATION] 'whitelists' is deprecated. Please use 'safelists' instead.", stderror + } + + describe "with a request match both safelist & blocklist" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua } - it "should allow whitelists before blacklists" do + it "should allow safelists before blocklists" do get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua last_response.status.must_equal 200 end it "should tag the env" do last_request.env['rack.attack.matched'].must_equal 'good ua' - last_request.env['rack.attack.match_type'].must_equal :whitelist + last_request.env['rack.attack.match_type'].must_equal :safelist end end end