diff --git a/.gitignore b/.gitignore index 8624f93..1bb1e05 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ bin *.gemfile.lock .ruby-version .ruby-gemset +.byebug_history diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..eea40b4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,58 @@ +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 2.3 + DisabledByDefault: true + Exclude: + - "examples/instrumentation.rb" + # Remove the following line once we are able to make bundler install gems to /vendor/bundle instead + # of /gemfiles/vendor/bundle during TravisCI builds. The reason that happens for now is because + # bundler 1.x only installs relative to the Gemfile (which during CI builds is always one inside gemfiles/ folder) + # instead of the CWD. Bundler 2.x will add support to install relative to CWD + # (see https://github.com/bundler/bundler/pull/5803). + - "gemfiles/vendor/**/*" + +Bundler: + Enabled: true + +Gemspec: + Enabled: true + +Layout: + Enabled: true + +Lint: + Enabled: true + +Naming: + Enabled: true + Exclude: + - "lib/rack/attack/path_normalizer.rb" + +Performance: + Enabled: true + +Security: + Enabled: true + +Lint: + Enabled: true + +Style/BlockDelimiters: + Enabled: true + +Style/BracesAroundHashParameters: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + +# TODO +# Remove cop disabling and fix offenses +Lint/HandleExceptions: + Enabled: false diff --git a/.travis.yml b/.travis.yml index 9eb7762..46bb12f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,42 @@ language: ruby +cache: bundler + rvm: - - 2.5.0 - - 2.4.3 - - 2.3.6 - - 2.2.9 - - jruby-9.1.14.0 + - 2.6.0-preview2 + - 2.5.1 + - 2.4.4 + - 2.3.7 + - jruby-9.1.16.0 before_install: - - gem update --system + # For jruby we need to stick with rubygems 2.7.4 until + # https://github.com/rubygems/rubygems/issues/2188 + # is fixed and released. + # + # Without this workaround, for jruby builds, rubygems + # activates jruby stdlib minitest (v5.4.1) instead of the + # bundled version (v5.11.3). + - if [ "${TRAVIS_RUBY_VERSION:0:5}" = "jruby" ]; then gem update --system 2.7.4; else gem update --system; fi - gem install bundler gemfile: + - gemfiles/rack_2_0.gemfile + - gemfiles/rack_1_6.gemfile + - gemfiles/rails_5_2.gemfile - gemfiles/rails_5_1.gemfile - - gemfiles/rails_5_0.gemfile - gemfiles/rails_4_2.gemfile - gemfiles/dalli2.gemfile + - gemfiles/connection_pool_dalli.gemfile + - gemfiles/active_support_redis_cache_store.gemfile + - gemfiles/active_support_redis_cache_store_pooled.gemfile + - gemfiles/redis_store.gemfile + - gemfiles/active_support_redis_store.gemfile + +matrix: + allow_failures: + - rvm: 2.6.0-preview2 + + fast_finish: true services: - redis diff --git a/Appraisals b/Appraisals index 56fadfc..2cabba7 100644 --- a/Appraisals +++ b/Appraisals @@ -1,18 +1,65 @@ -appraise 'rails_5-1' do - gem 'activesupport', '~> 5.1.0' - gem 'actionpack', '~> 5.1.0' +# frozen_string_literal: true + +appraise "rack_2_0" do + gem "rack", "~> 2.0.4" end -appraise 'rails_5-0' do - gem 'activesupport', '~> 5.0.0' - gem 'actionpack', '~> 5.0.0' +appraise "rack_1_6" do + # Override activesupport and actionpack version constraints by making + # it more loose so it's compatible with rack 1.6.x + gem "actionpack", ">= 4.2" + gem "activesupport", ">= 4.2" + + gem "rack", "~> 1.6.9" + + # Override rack-test version constraint by making it more loose + # so it's compatible with actionpack 4.2.x + gem "rack-test", ">= 0.6" +end + +appraise 'rails_5-2' do + gem 'actionpack', '~> 5.2.0' + gem 'activesupport', '~> 5.2.0' +end + +appraise 'rails_5-1' do + gem 'actionpack', '~> 5.1.0' + gem 'activesupport', '~> 5.1.0' end appraise 'rails_4-2' do - gem 'activesupport', '~> 4.2.0' gem 'actionpack', '~> 4.2.0' + gem 'activesupport', '~> 4.2.0' + + # Override rack-test version constraint by making it more loose + # so it's compatible with actionpack 4.2.x + gem "rack-test", ">= 0.6" end appraise 'dalli2' do gem 'dalli', '~> 2.0' end + +appraise "connection_pool_dalli" do + gem "connection_pool", "~> 2.2" + gem "dalli", "~> 2.7" +end + +appraise "active_support_redis_cache_store" do + gem "activesupport", "~> 5.2.0" + gem "redis", "~> 4.0" +end + +appraise "active_support_redis_cache_store_pooled" do + gem "activesupport", "~> 5.2.0" + gem "connection_pool", "~> 2.2" + gem "redis", "~> 4.0" +end + +appraise "redis_store" do + gem "redis-store", "~> 1.5" +end + +appraise "active_support_redis_store" do + gem "redis-activesupport", "~> 5.0" +end diff --git a/CHANGELOG.md b/CHANGELOG.md index cec9633..c8c798a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,84 +1,147 @@ -# Changlog +# Changelog -## [New Releases here](https://github.com/kickstarter/rack-attack/releases) +All notable changes to this project will be documented in this file. -This file is kept for historical documentation. +## [Unreleased] -## v5.0.0.beta1 4 July 2016 +_No significant changes since last release yet. Stay tuned_ :radio: - - Deprecate `whitelist`/`blacklist` in favor of `safelist`/`blocklist`. (#181, +## [5.3.2] - 2018-06-25 + +### Fixed + +- Don't raise exception `The Redis cache store requires the redis gem` when using [`ActiveSupport::Cache::MemoryStore`](http://api.rubyonrails.org/classes/ActiveSupport/Cache/MemoryStore.html) as a cache store backend + +## [5.3.1] - 2018-06-20 + +### Fixed + +- Make [`ActiveSupport::Cache::RedisCacheStore`](http://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html) also work as excepted when initialized with pool options (e.g. `pool_size`) + +## [5.3.0] - 2018-06-19 + +### Added + +- Add support for [`ActiveSupport::Cache::RedisCacheStore`](http://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html) as a store backend ([#340](https://github.com/kickstarter/rack-attack/pull/340) and [#350](https://github.com/kickstarter/rack-attack/pull/350)) + +## [5.2.0] - 2018-03-29 + +### Added + +- Shorthand for blocking an IP address `Rack::Attack.blocklist_ip("1.2.3.4")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) +- Shorthand for blocking an IP subnet `Rack::Attack.blocklist_ip("1.2.0.0/16")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) +- Shorthand for safelisting an IP address `Rack::Attack.safelist_ip("5.6.7.8")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) +- Shorthand for safelisting an IP subnet `Rack::Attack.safelist_ip("5.6.0.0/16")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) +- 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)) + +## [5.1.0] - 2018-03-10 + + - Fixes edge case bug when using ruby 2.5.0 and redis [#253](https://github.com/kickstarter/rack-attack/issues/253) ([#271](https://github.com/kickstarter/rack-attack/issues/271)) + - Throws errors with better semantics when missing or misconfigured store caches to aid in developers debugging their configs ([#274](https://github.com/kickstarter/rack-attack/issues/274)) + - Removed legacy code that was originally intended for Rails 3 apps ([#264](https://github.com/kickstarter/rack-attack/issues/264)) + +## [5.0.1] - 2016-08-11 + + - Fixes arguments passed to deprecated internal methods. ([#198](https://github.com/kickstarter/rack-attack/issues/198)) + +## [5.0.0] - 2016-08-09 + + - Deprecate `whitelist`/`blacklist` in favor of `safelist`/`blocklist`. ([#181](https://github.com/kickstarter/rack-attack/issues/181), thanks @renee-travisci). To upgrade and fix deprecations, find and replace instances of `whitelist` and `blacklist` with `safelist` and `blocklist`. If you reference `rack.attack.match_type`, note that it will have values like `:safelist`/`:blocklist`. - Remove test coverage for unsupported ruby dependencies: ruby 2.0, activesupport 3.2/4.0, and dalli 1. -## v4.4.1 17 Feb 2016 +## [4.4.1] - 2016-02-17 - Fix a bug affecting apps using Redis::Store and ActiveSupport that could generate an error - saying dalli was a required dependency. I learned all about ActiveSupport autoloading. (#165) + saying dalli was a required dependency. I learned all about ActiveSupport autoloading. ([#165](https://github.com/kickstarter/rack-attack/issues/165)) -## v4.4.0 - 10 Feb 2016 +## [4.4.0] - 2016-02-10 - - New: support for MemCacheStore (#153). Thanks @elhu. + - New: support for MemCacheStore ([#153](https://github.com/kickstarter/rack-attack/issues/153)). Thanks @elhu. - Some documentation and test harness improvements. -## v4.3.1 - 18 Dec 2015 +## [4.3.1] - 2015-12-18 - SECURITY FIX: Normalize request paths when using ActionDispatch. Thanks Andres Riancho at @includesecurity for reporting it. - Remove support for ruby 1.9.x - Add Code of Conduct - Several documentation and testing improvements -## v4.3.0 - 22 May 2015 +## [4.3.0] - 2015-05-22 - Redis proxy passes `raw: true` (thanks @stanhu) - Redis supports `delete` method to be consistent with Dalli (thanks @stanhu) - Support the ability to reset Fail2Ban count and ban flag (thanks @stanhu) -## v4.2.0 - 26 Oct 2014 +## [4.2.0] - 2014-10-26 - Throttle's `period` argument now takes a proc as well as a number (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 +## [4.1.1] - 2014-09-11 - Fix a race condition in throttles that could allow more requests than intended. -## v4.1.0 - 22 May 2014 +## [4.1.0] - 2014-05-22 - Tracks take an optional limit and period to only notify once a threshold is reached (similar to throttles). Thanks @chiliburger! - Default throttled & blocklist responses have Content-Type: text/plain - Rack::Attack.clear! resets tracks -## v4.0.1 - 14 May 2014 - * Add throttle discriminator to rack env (thanks @blahed) +## [4.0.1] - 2014-05-14 + - Add throttle discriminator to rack env (thanks @blahed) -## v4.0.0 - 28 April 2014 - * Implement proxy for Dalli with better Memcachier support. (thanks @hakanensari) - * Rack::Attack.new returns an instance to ease testing. (thanks @stevehodgkiss) +## [4.0.0] - 2014-04-28 + - Implement proxy for Dalli with better Memcachier support. (thanks @hakanensari) + - Rack::Attack.new returns an instance to ease testing. (thanks @stevehodgkiss) [Changing a module to a class is not backwards compatible, hence v4.0.0.] - * Use Rack::Attack::Request subclass of Rack::Request for easier extending (thanks @tristandunn) - * Test more dalli versions. + - Use Rack::Attack::Request subclass of Rack::Request for easier extending (thanks @tristandunn) + - Test more dalli versions. -## v3.0.0 - 15 March 2014 - * Change default blocklisted response to 403 Forbidden (thanks @carpodaster). - * Fail gracefully when Redis store is not available; rescue exeption and don't +## [3.0.0] - 2014-03-15 + - 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. + - TravisCI runs integration tests. -## v2.3.0 - 11 October 2013 - * Allow throttle `limit` argument to be a proc. (thanks @lunks) - * Add Allow2Ban, complement of Fail2Ban. (thanks @jormon) - * Improved TravisCI testing +## [2.3.0] - 2013-10-11 + - Allow throttle `limit` argument to be a proc. (thanks @lunks) + - Add Allow2Ban, complement of Fail2Ban. (thanks @jormon) + - Improved TravisCI testing -## v2.2.1 - 13 August 2013 - * Add license to gemspec - * Support ruby version 1.9.2 - * Change default blocklisted response code from 503 to 401; throttled response +## [2.2.1] - 2013-08-13 + - Add license to gemspec + - Support ruby version 1.9.2 + - Change default blocklisted response code from 503 to 401; throttled response from 503 to 429. -## v2.2.0 - 20 June 2013 - * Fail2Ban filtering. See README for details. Thx @madlep! - * Introduce StoreProxy to more cleanly abstract cache stores. Thx @madlep. +## [2.2.0] - 2013-06-20 + - Fail2Ban filtering. See README for details. Thx @madlep! + - Introduce StoreProxy to more cleanly abstract cache stores. Thx @madlep. -## v2.1.1 - 16 May 2013 - * Start keeping changelog - * Fix `Redis::CommandError` when using ActiveSupport numeric extensions (e.g. `1.second`) - * Remove unused variable - * Extract mandatory options to constants +## 2.1.1 - 2013-05-16 + - Start keeping changelog + - Fix `Redis::CommandError` when using ActiveSupport numeric extensions (e.g. `1.second`) + - Remove unused variable + - Extract mandatory options to constants + +[Unreleased]: https://github.com/kickstarter/rack-attack/compare/v5.3.2...HEAD/ +[5.3.2]: https://github.com/kickstarter/rack-attack/compare/v5.3.1...v5.3.2/ +[5.3.1]: https://github.com/kickstarter/rack-attack/compare/v5.3.0...v5.3.1/ +[5.3.0]: https://github.com/kickstarter/rack-attack/compare/v5.2.0...v5.3.0/ +[5.2.0]: https://github.com/kickstarter/rack-attack/compare/v5.1.0...v5.2.0/ +[5.1.0]: https://github.com/kickstarter/rack-attack/compare/v5.0.1...v5.1.0/ +[5.0.1]: https://github.com/kickstarter/rack-attack/compare/v5.0.0...v5.0.1/ +[5.0.0]: https://github.com/kickstarter/rack-attack/compare/v4.4.1...v5.0.0/ +[4.4.1]: https://github.com/kickstarter/rack-attack/compare/v4.4.0...v4.4.1/ +[4.4.0]: https://github.com/kickstarter/rack-attack/compare/v4.3.1...v4.4.0/ +[4.3.1]: https://github.com/kickstarter/rack-attack/compare/v4.3.0...v4.3.1/ +[4.3.0]: https://github.com/kickstarter/rack-attack/compare/v4.2.0...v4.3.0/ +[4.2.0]: https://github.com/kickstarter/rack-attack/compare/v4.1.1...v4.2.0/ +[4.1.1]: https://github.com/kickstarter/rack-attack/compare/v4.1.0...v4.1.1/ +[4.1.0]: https://github.com/kickstarter/rack-attack/compare/v4.0.1...v4.1.0/ +[4.0.1]: https://github.com/kickstarter/rack-attack/compare/v4.0.0...v4.0.1/ +[4.0.0]: https://github.com/kickstarter/rack-attack/compare/v3.0.0...v4.0.0/ +[3.0.0]: https://github.com/kickstarter/rack-attack/compare/v2.3.0...v3.0.0/ +[2.3.0]: https://github.com/kickstarter/rack-attack/compare/v2.2.1...v2.3.0/ +[2.2.1]: https://github.com/kickstarter/rack-attack/compare/v2.2.0...v2.2.1/ +[2.2.0]: https://github.com/kickstarter/rack-attack/compare/v2.1.1...v2.2.0/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ad44a62..a05c39e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -18,4 +18,4 @@ Instances of abusive, harassing, or otherwise unacceptable behavior may be repor :hand: :page_with_curl: -This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org) (v1.0.0), available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org) (v1.0.0), available at [https://www.contributor-covenant.org/version/1/0/0/](https://www.contributor-covenant.org/version/1/0/0/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d664561 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Rack::Attack: Contributing + +Thank you for considering contributing to Rack::Attack. + +This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). + +## How can I help? + +Any of the following is greatly appreciated: + +* Helping users by answering to their [questions](https://github.com/kickstarter/rack-attack/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+question%22) +* Helping users troubleshoot their [error reports](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+error+report%22) to figure out if the error is caused by an actual bug or some misconfiguration +* Giving feedback by commenting in other users [feature requests](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+feature+request%22) +* Reporting an error you are experiencing +* Suggesting a new feature you think it would be useful for many users +* If you want to work on fixing an actual issue and you don't know where to start, those labeled [good first issue](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) may be a good choice + +## Style Guide + +As an effort to keep the codebase consistent, we encourage the use of [Rubocop](https://github.com/bbatsov/rubocop). +This tool helps us abstract most of the decisions we have to make when coding. + +To check your code, simply type `bundle exec rubocop` in the shell. The resulting output are all the offenses currently present in the code. + +It is highly recommended that you integrate a linter with your editor. +This way you receive real time feedback about your code. Most editors have some kind of plugin for that. diff --git a/Gemfile b/Gemfile index 5d00c12..7f4f5e9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec - -group :development do - gem 'pry' - gem 'guard' # NB: this is necessary in newer versions - gem 'guard-minitest' -end diff --git a/Guardfile b/Guardfile deleted file mode 100644 index ebf900b..0000000 --- a/Guardfile +++ /dev/null @@ -1,10 +0,0 @@ -# A sample Guardfile -# More info at https://github.com/guard/guard#readme - -guard :minitest do - # with Minitest::Spec - watch(%r{^spec/(.*)_spec\.rb$}) - watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } - watch(%r{^spec/spec_helper\.rb$}) { 'spec' } -end - diff --git a/README.md b/README.md index b5381e7..9a09558 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,340 @@ -# Rack::Attack!!! +# Rack::Attack + *Rack middleware for blocking & throttling abusive requests* -Rack::Attack is a rack middleware to protect your web app from bad clients. -It allows *safelisting*, *blocklisting*, *throttling*, and *tracking* based on arbitrary properties of the request. +Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to *allow*, *block* and *throttle* based on 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)). +See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack. -See the [Backing & Hacking blog post](http://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack. - -[![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](http://badge.fury.io/rb/rack-attack) +[![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](https://badge.fury.io/rb/rack-attack) [![Build Status](https://travis-ci.org/kickstarter/rack-attack.svg?branch=master)](https://travis-ci.org/kickstarter/rack-attack) [![Code Climate](https://codeclimate.com/github/kickstarter/rack-attack.svg)](https://codeclimate.com/github/kickstarter/rack-attack) -## Looking for maintainers - -I'm looking for new maintainers to help me support Rack::Attack. Check out -[issue #219 for details](https://github.com/kickstarter/rack-attack/issues/219). - ## Getting started -Install the [rack-attack](http://rubygems.org/gems/rack-attack) gem; or add it to your Gemfile with bundler: +### 1. Installing + +Add this line to your application's Gemfile: ```ruby # In your Gemfile + gem 'rack-attack' ``` -Tell your app to use the Rack::Attack middleware. -For Rails apps: + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install rack-attack + +### 2. Plugging into the application + +Then tell your ruby web application to use rack-attack as a middleware. + +a) For __rails__ applications: ```ruby # In config/application.rb + config.middleware.use Rack::Attack ``` -Or for Rackup files: +b) For __rack__ applications: ```ruby # In config.ru + +require "rack/attack" use Rack::Attack ``` -Add a `rack-attack.rb` file to `config/initializers/`: -```ruby -# In config/initializers/rack-attack.rb -class Rack::Attack - # your custom configuration... -end -``` +__IMPORTANT__: By default, rack-attack won't perform any blocking or throttling, until you specifically tell it what to protect against by configuring some rules. + +## Usage *Tip:* The example in the wiki is a great way to get started: [Example Configuration](https://github.com/kickstarter/rack-attack/wiki/Example-Configuration) -Optionally configure the cache store for throttling or fail2ban filtering: +Define rules by calling `Rack::Attack` public methods, in any file that runs when your application is being initialized. For rails applications this means creating a new file named `config/initializers/rack_attack.rb` and writing your rules there. + +### Safelisting + +Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles. + +#### `safelist_ip(ip_address_string)` + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails app) + +Rack::Attack.safelist_ip("5.6.7.8") +``` + +#### `safelist_ip(ip_subnet_string)` + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails app) + +Rack::Attack.safelist_ip("5.6.7.0/24") +``` + +#### `safelist(name, &block)` + +Name your custom safelist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise. + +The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails apps) + +# Provided that trusted users use an HTTP request header named APIKey +Rack::Attack.safelist("mark any authenticated access safe") do |request| + # Requests are allowed if the return value is truthy + request.env["APIKey"] == "secret-string" +end + +# Always allow requests from localhost +# (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 +``` + +### Blocking + +#### `blocklist_ip(ip_address_string)` + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails apps) + +Rack::Attack.blocklist_ip("1.2.3.4") +``` + +#### `blocklist_ip(ip_subnet_string)` + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails apps) + +Rack::Attack.blocklist_ip("1.2.0.0/16") +``` + +#### `blocklist(name, &block)` + +Name your custom blocklist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise. + +The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails apps) + +Rack::Attack.blocklist("block all access to admin") do |request| + # Requests are blocked if the return value is truthy + request.path.start_with?("/admin") +end + +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 blocklist to block all requests from misbehaving clients. +This pattern is inspired by [fail2ban](https://www.fail2ban.org/wiki/index.php/Main_Page). +See the [fail2ban documentation](https://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 blocklist and use a unique discriminator for each fail2ban filter. + +Fail2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). + +```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.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 + # The count for the IP is incremented if the return value is truthy + CGI.unescape(req.query_string) =~ %r{/etc/passwd} || + req.path.include?('/etc/passwd') || + req.path.include?('wp-admin') || + req.path.include?('wp-login') + + end +end +``` + +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 +clients until such time as they reach maxretry at which they are cut off as per normal. + +Allow2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). + +```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.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. + Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do + # The count for the IP is incremented if the return value is truthy. + req.path == '/login' and req.post? + end +end +``` + +### Throttling + +Throttle state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present). + +#### `throttle(name, options, &block)` + +Name your custom throttle, provide `limit` and `period` as options, and make your ruby-block argument return the __discriminator__. This discriminator is how you tell rack-attack whether you're limiting per IP address, per user email or any other. + +The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). + +E.g. + +```ruby +# config/initializers/rack_attack.rb (for rails apps) + +Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request| + request.ip +end + +# Throttle login attempts for a given email parameter to 6 reqs/minute +# Return the email as a discriminator on POST /login requests +Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req| + if req.path == '/login' && req.post? + req.params['email'] + end +end + +# You can also set a limit and period using a proc. For instance, after +# Rack::Auth::Basic has authenticated the user: +limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 } +period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 } + +Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request| + request.ip +end +``` + +### Tracks + +```ruby +# Track requests from a special user agent. +Rack::Attack.track("special_agent") do |req| + req.user_agent == "SpecialAgent" +end + +# Supports optional limit and period, triggers the notification only when the limit is reached. +Rack::Attack.track("special_agent", limit: 6, period: 60) do |req| + req.user_agent == "SpecialAgent" +end + +# Track it using ActiveSupport::Notification +ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, payload| + req = payload[:request] + if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track + Rails.logger.info "special_agent: #{req.path}" + STATSD.increment("special_agent") + end +end +``` + +### Cache store configuration + +Throttle, allow2ban and fail2ban state is stored in a configurable cache (which defaults to `Rails.cache` if present), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)). ```ruby 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 blocklisting & safelisting. 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, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). + +## Customizing responses + +Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC). + +```ruby +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 blocklists by default + [ 503, {}, ['Blocked']] +end + +Rack::Attack.throttled_response = lambda do |env| + # NB: you have access to the name and other data about the matched throttle + # env['rack.attack.matched'], + # env['rack.attack.match_type'], + # env['rack.attack.match_data'], + # env['rack.attack.match_discriminator'] + + # Using 503 because it may make attacker think that they have successfully + # DOSed the site. Rack::Attack returns 429 for throttling by default + [ 503, {}, ["Server Error\n"]] +end +``` + +### X-RateLimit headers for well-behaved clients + +While Rack::Attack's primary focus is minimizing harm from abusive clients, it +can also be used to return rate limit data that's helpful for well-behaved clients. + +Here's an example response that includes conventional `X-RateLimit-*` headers: + +```ruby +Rack::Attack.throttled_response = lambda do |env| + match_data = env['rack.attack.match_data'] + now = match_data[:epoch_time] + + headers = { + 'X-RateLimit-Limit' => match_data[:limit].to_s, + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s + } + + [ 429, headers, ["Throttled\n"]] +end +``` + + +For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data: + +```ruby +request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t } +``` + +## Logging & Instrumentation + +Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available. + +You can subscribe to 'rack.attack' events and log it, graph it, etc: + +```ruby +ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, payload| + puts payload[:request].inspect +end +``` ## How it works @@ -91,200 +368,10 @@ Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you can cleanly monkey patch helper methods onto the [request object](https://github.com/kickstarter/rack-attack/blob/master/lib/rack/attack/request.rb). -## About Tracks +### About Tracks `Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. -## Usage - -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). - -### Safelists - -```ruby -# Always allow requests from localhost -# (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 -``` - -### Blocklists - -```ruby -# Block requests from 1.2.3.4 -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.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 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 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.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 - # The count for the IP is incremented if the return value is truthy - CGI.unescape(req.query_string) =~ %r{/etc/passwd} || - req.path.include?('/etc/passwd') || - req.path.include?('wp-admin') || - req.path.include?('wp-login') - - end -end -``` - -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 -clients until such time as they reach maxretry at which they are cut off as per normal. -```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.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. - Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do - # The count for the IP is incremented if the return value is truthy. - req.path == '/login' and req.post? - end -end -``` - - -### Throttles - -```ruby -# Throttle requests to 5 requests per second per ip -Rack::Attack.throttle('req/ip', limit: 5, period: 1.second) do |req| - # If the return value is truthy, the cache key for the return value - # is incremented and compared with the limit. In this case: - # "rack::attack:#{Time.now.to_i/1.second}:req/ip:#{req.ip}" - # - # If falsy, the cache key is neither incremented nor checked. - - req.ip -end - -# Throttle login attempts for a given email parameter to 6 reqs/minute -# Return the email as a discriminator on POST /login requests -Rack::Attack.throttle('logins/email', limit: 6, period: 60) do |req| - req.params['email'] if req.path == '/login' && req.post? -end - -# You can also set a limit and period using a proc. For instance, after -# Rack::Auth::Basic has authenticated the user: -limit_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 100 : 1} -period_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 1.second : 1.minute} -Rack::Attack.throttle('req/ip', limit: limit_proc, period: period_proc) do |req| - req.ip -end -``` - -### Tracks - -```ruby -# Track requests from a special user agent. -Rack::Attack.track("special_agent") do |req| - req.user_agent == "SpecialAgent" -end - -# Supports optional limit and period, triggers the notification only when the limit is reached. -Rack::Attack.track("special_agent", limit: 6, period: 60) do |req| - req.user_agent == "SpecialAgent" -end - -# Track it using ActiveSupport::Notification -ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, req| - if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track - Rails.logger.info "special_agent: #{req.path}" - STATSD.increment("special_agent") - end -end -``` - -## Responses - -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.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 blocklists by default - [ 503, {}, ['Blocked']] -end - -Rack::Attack.throttled_response = lambda do |env| - # NB: you have access to the name and other data about the matched throttle - # env['rack.attack.matched'], - # env['rack.attack.match_type'], - # env['rack.attack.match_data'] - - # Using 503 because it may make attacker think that they have successfully - # DOSed the site. Rack::Attack returns 429 for throttling by default - [ 503, {}, ["Server Error\n"]] -end -``` - -### X-RateLimit headers for well-behaved clients - -While Rack::Attack's primary focus is minimizing harm from abusive clients, it -can also be used to return rate limit data that's helpful for well-behaved clients. - -Here's an example response that includes conventional `X-RateLimit-*` headers: - -```ruby -Rack::Attack.throttled_response = lambda do |env| - now = Time.now - match_data = env['rack.attack.match_data'] - - headers = { - 'X-RateLimit-Limit' => match_data[:limit].to_s, - 'X-RateLimit-Remaining' => '0', - 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s - } - - [ 429, headers, ["Throttled\n"]] -end -``` - - -For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data: - -```ruby -request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l } -``` - -## Logging & Instrumentation - -Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available. - -You can subscribe to 'rack.attack' events and log it, graph it, etc: - -```ruby -ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req| - puts req.inspect -end -``` ## Testing @@ -302,7 +389,7 @@ so try to keep the number of throttle checks per request low. 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). +Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone). ## Motivation @@ -316,26 +403,15 @@ less on short-term, one-off hacks to block a particular attack. ## Contributing -Pull requests and issues are greatly appreciated. This project is intended to be -a safe, welcoming space for collaboration, and contributors are expected to -adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). +Check out the [Contributing guide](CONTRIBUTING.md). -### Testing pull requests +## Code of Conduct -To run the minitest test suite, you will need both [Redis](http://redis.io/) and -[Memcached](https://memcached.org/) running locally and bound to IP `127.0.0.1` on -default ports (`6379` for Redis, and `11211` for Memcached) and able to be -accessed without authentication. +This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md). -Install dependencies by running -```sh -bundle install -``` +## Development setup -Then run the test suite by running -```sh -bundle exec rake -``` +Check out the [Development guide](docs/development.md). ## Mailing list @@ -348,4 +424,4 @@ New releases of Rack::Attack are announced on Copyright Kickstarter, PBC. -Released under an [MIT License](http://opensource.org/licenses/MIT). +Released under an [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile index 300ec21..f08b537 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,12 @@ +# frozen_string_literal: true + require "rubygems" require "bundler/setup" require 'bundler/gem_tasks' require 'rake/testtask' +require "rubocop/rake_task" + +RuboCop::RakeTask.new namespace :test do Rake::TestTask.new(:units) do |t| @@ -11,9 +16,14 @@ namespace :test do Rake::TestTask.new(:integration) do |t| t.pattern = "spec/integration/*_spec.rb" end + + Rake::TestTask.new(:acceptance) do |t| + t.pattern = "spec/acceptance/**/*_spec.rb" + end end -desc 'Run tests' -task :test => %w[test:units test:integration] +Rake::TestTask.new(:test) do |t| + t.pattern = "spec/**/*_spec.rb" +end -task :default => :test +task :default => [:rubocop, :test] diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..c3fec28 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,13 @@ +# Rack::Attack: Development + +## Running the tests + +You will need both [Redis](https://redis.io/) and [Memcached](https://memcached.org/) running locally and bound to IP `127.0.0.1` on default ports (`6379` for Redis, and `11211` for Memcached) and able to be accessed without authentication. + +Install dependencies by running + + $ bundle install + +Then run the test suite by running + + $ bundle exec appraisal rake test diff --git a/examples/rack_attack.rb b/examples/rack_attack.rb index eea07c0..43f1348 100644 --- a/examples/rack_attack.rb +++ b/examples/rack_attack.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # NB: `req` is a Rack::Request object (basically an env hash with friendly accessor methods) # Throttle 10 requests/ip/second diff --git a/gemfiles/active_support_redis_cache_store.gemfile b/gemfiles/active_support_redis_cache_store.gemfile new file mode 100644 index 0000000..040271b --- /dev/null +++ b/gemfiles/active_support_redis_cache_store.gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 5.2.0" +gem "redis", "~> 4.0" + +gemspec path: "../" diff --git a/gemfiles/active_support_redis_cache_store_pooled.gemfile b/gemfiles/active_support_redis_cache_store_pooled.gemfile new file mode 100644 index 0000000..357ca42 --- /dev/null +++ b/gemfiles/active_support_redis_cache_store_pooled.gemfile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 5.2.0" +gem "connection_pool", "~> 2.2" +gem "redis", "~> 4.0" + +gemspec path: "../" diff --git a/gemfiles/active_support_redis_store.gemfile b/gemfiles/active_support_redis_store.gemfile new file mode 100644 index 0000000..922f3cc --- /dev/null +++ b/gemfiles/active_support_redis_store.gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "redis-activesupport", "~> 5.0" + +gemspec path: "../" diff --git a/gemfiles/connection_pool_dalli.gemfile b/gemfiles/connection_pool_dalli.gemfile new file mode 100644 index 0000000..8c62b05 --- /dev/null +++ b/gemfiles/connection_pool_dalli.gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "connection_pool", "~> 2.2" +gem "dalli", "~> 2.7" + +gemspec path: "../" diff --git a/gemfiles/dalli2.gemfile b/gemfiles/dalli2.gemfile index f955198..1628ad6 100644 --- a/gemfiles/dalli2.gemfile +++ b/gemfiles/dalli2.gemfile @@ -1,13 +1,9 @@ +# frozen_string_literal: true + # This file was generated by Appraisal source "https://rubygems.org" gem "dalli", "~> 2.0" -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - gemspec path: "../" diff --git a/gemfiles/rack_1_6.gemfile b/gemfiles/rack_1_6.gemfile new file mode 100644 index 0000000..91789b4 --- /dev/null +++ b/gemfiles/rack_1_6.gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "actionpack", ">= 4.2" +gem "activesupport", ">= 4.2" +gem "rack", "~> 1.6.9" +gem "rack-test", ">= 0.6" + +gemspec path: "../" diff --git a/gemfiles/rack_2_0.gemfile b/gemfiles/rack_2_0.gemfile new file mode 100644 index 0000000..c7866b6 --- /dev/null +++ b/gemfiles/rack_2_0.gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "rack", "~> 2.0.4" + +gemspec path: "../" diff --git a/gemfiles/rails_4_2.gemfile b/gemfiles/rails_4_2.gemfile index d4532c5..5e7dfe3 100644 --- a/gemfiles/rails_4_2.gemfile +++ b/gemfiles/rails_4_2.gemfile @@ -1,14 +1,11 @@ +# frozen_string_literal: true + # This file was generated by Appraisal source "https://rubygems.org" -gem "activesupport", "~> 4.2.0" gem "actionpack", "~> 4.2.0" - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end +gem "activesupport", "~> 4.2.0" +gem "rack-test", ">= 0.6" gemspec path: "../" diff --git a/gemfiles/rails_5_0.gemfile b/gemfiles/rails_5_0.gemfile deleted file mode 100644 index 0fea132..0000000 --- a/gemfiles/rails_5_0.gemfile +++ /dev/null @@ -1,14 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "activesupport", "~> 5.0.0" -gem "actionpack", "~> 5.0.0" - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - -gemspec path: "../" diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile index bb501ef..bac5d16 100644 --- a/gemfiles/rails_5_1.gemfile +++ b/gemfiles/rails_5_1.gemfile @@ -1,14 +1,10 @@ +# frozen_string_literal: true + # This file was generated by Appraisal source "https://rubygems.org" -gem "activesupport", "~> 5.1.0" gem "actionpack", "~> 5.1.0" - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end +gem "activesupport", "~> 5.1.0" gemspec path: "../" diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile new file mode 100644 index 0000000..1873512 --- /dev/null +++ b/gemfiles/rails_5_2.gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "actionpack", "~> 5.2.0" +gem "activesupport", "~> 5.2.0" + +gemspec path: "../" diff --git a/gemfiles/redis_store.gemfile b/gemfiles/redis_store.gemfile new file mode 100644 index 0000000..0ba921a --- /dev/null +++ b/gemfiles/redis_store.gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "redis-store", "~> 1.5" + +gemspec path: "../" diff --git a/lib/rack/attack.rb b/lib/rack/attack.rb index 346fbe5..f59e155 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -1,43 +1,51 @@ +# frozen_string_literal: true + require 'rack' require 'forwardable' +require 'rack/attack/path_normalizer' +require 'rack/attack/request' +require "ipaddr" class Rack::Attack - autoload :Cache, 'rack/attack/cache' - autoload :PathNormalizer, 'rack/attack/path_normalizer' - autoload :Check, 'rack/attack/check' - autoload :Throttle, 'rack/attack/throttle' - 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' - autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy' - autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy' - autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy' - autoload :Fail2Ban, 'rack/attack/fail2ban' - autoload :Allow2Ban, 'rack/attack/allow2ban' - autoload :Request, 'rack/attack/request' + class MisconfiguredStoreError < StandardError; end + class MissingStoreError < StandardError; end + + autoload :Cache, 'rack/attack/cache' + autoload :Check, 'rack/attack/check' + autoload :Throttle, 'rack/attack/throttle' + 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' + autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy' + autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy' + autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy' + autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy' + autoload :Fail2Ban, 'rack/attack/fail2ban' + autoload :Allow2Ban, 'rack/attack/allow2ban' class << self - attr_accessor :notifier, :blocklisted_response, :throttled_response def safelist(name, &block) self.safelists[name] = Safelist.new(name, block) end - def whitelist(name, &block) - warn "[DEPRECATION] 'Rack::Attack.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) - warn "[DEPRECATION] 'Rack::Attack.blacklist' is deprecated. Please use 'blocklist' instead." - blocklist(name, &block) + def blocklist_ip(ip_address) + @ip_blocklists ||= [] + ip_blocklist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } + @ip_blocklists << Blocklist.new(nil, ip_blocklist_proc) + end + + def safelist_ip(ip_address) + @ip_safelists ||= [] + ip_safelist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } + @ip_safelists << Safelist.new(nil, ip_safelist_proc) end def throttle(name, options, &block) @@ -49,84 +57,71 @@ class Rack::Attack end def safelists; @safelists ||= {}; end + def blocklists; @blocklists ||= {}; end + def throttles; @throttles ||= {}; end + def tracks; @tracks ||= {}; end - def whitelists - warn "[DEPRECATION] 'Rack::Attack.whitelists' is deprecated. Please use 'safelists' instead." - safelists + def safelisted?(request) + ip_safelists.any? { |safelist| safelist.matched_by?(request) } || + safelists.any? { |_name, safelist| safelist.matched_by?(request) } end - def blacklists - warn "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead." - blocklists + def blocklisted?(request) + ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } || + blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) } end - def safelisted?(req) - safelists.any? do |name, safelist| - safelist[req] + def throttled?(request) + throttles.any? do |_name, throttle| + throttle.matched_by?(request) end end - def whitelisted?(req) - warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead." - safelisted?(req) - end - - def blocklisted?(req) - blocklists.any? do |name, blocklist| - blocklist[req] + def tracked?(request) + tracks.each_value do |track| + track.matched_by?(request) end end - def blacklisted?(req) - warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead." - blocklisted?(req) - end - - def throttled?(req) - throttles.any? do |name, throttle| - throttle[req] - end - end - - def tracked?(req) - tracks.each_value do |tracker| - tracker[req] - end - end - - def instrument(req) - notifier.instrument('rack.attack', req) if notifier + def instrument(request) + notifier.instrument('rack.attack', request: request) if notifier end def cache @cache ||= Cache.new end - def clear! + def clear_configuration @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {} + @ip_blocklists = [] + @ip_safelists = [] end - def blacklisted_response=(res) - warn "[DEPRECATION] 'Rack::Attack.blacklisted_response=' is deprecated. Please use 'blocklisted_response=' instead." - self.blocklisted_response=(res) + def clear! + warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead" + clear_configuration end - def blacklisted_response - warn "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead." - blocklisted_response + private + + def ip_blocklists + @ip_blocklists ||= [] end + def ip_safelists + @ip_safelists ||= [] + end end # Set defaults @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) - @blocklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] } - @throttled_response = lambda {|env| + @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"]] + [429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]] } def initialize(app) @@ -135,23 +130,20 @@ class Rack::Attack def call(env) env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) - req = Rack::Attack::Request.new(env) + request = Rack::Attack::Request.new(env) - if safelisted?(req) + if safelisted?(request) @app.call(env) - elsif blocklisted?(req) + elsif blocklisted?(request) self.class.blocklisted_response.call(env) - elsif throttled?(req) + elsif throttled?(request) self.class.throttled_response.call(env) else - tracked?(req) + tracked?(request) @app.call(env) end end extend Forwardable - def_delegators self, :safelisted?, - :blocklisted?, - :throttled?, - :tracked? + def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked? end diff --git a/lib/rack/attack/allow2ban.rb b/lib/rack/attack/allow2ban.rb index 763253b..faa3518 100644 --- a/lib/rack/attack/allow2ban.rb +++ b/lib/rack/attack/allow2ban.rb @@ -1,8 +1,11 @@ +# frozen_string_literal: true + module Rack class Attack class Allow2Ban < Fail2Ban class << self protected + def key_prefix 'allow2ban' end diff --git a/lib/rack/attack/blocklist.rb b/lib/rack/attack/blocklist.rb index b61a29f..ee57656 100644 --- a/lib/rack/attack/blocklist.rb +++ b/lib/rack/attack/blocklist.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack class Blocklist < Check @@ -5,7 +7,6 @@ module Rack super @type = :blocklist end - end end end diff --git a/lib/rack/attack/cache.rb b/lib/rack/attack/cache.rb index c2dd06c..1c8030e 100644 --- a/lib/rack/attack/cache.rb +++ b/lib/rack/attack/cache.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module Rack class Attack class Cache - attr_accessor :prefix + attr_reader :last_epoch_time def initialize self.store = ::Rails.cache if defined?(::Rails.cache) @@ -20,6 +22,9 @@ module Rack end def read(unprefixed_key) + enforce_store_presence! + enforce_store_method_presence!(:read) + store.read("#{prefix}:#{unprefixed_key}") end @@ -39,22 +44,38 @@ module Rack private def key_and_expiry(unprefixed_key, period) - epoch_time = Time.now.to_i - # Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA - expires_in = (period - (epoch_time % period) + 1).to_i - ["#{prefix}:#{(epoch_time / period).to_i}:#{unprefixed_key}", expires_in] + @last_epoch_time = Time.now.to_i + # Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA + expires_in = (period - (@last_epoch_time % period) + 1).to_i + ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in] end def do_count(key, expires_in) + enforce_store_presence! + enforce_store_method_presence!(:increment) + result = store.increment(key, 1, :expires_in => expires_in) # NB: Some stores return nil when incrementing uninitialized values if result.nil? + enforce_store_method_presence!(:write) + store.write(key, 1, :expires_in => expires_in) end result || 1 end + def enforce_store_presence! + if store.nil? + raise Rack::Attack::MissingStoreError + end + end + + def enforce_store_method_presence!(method_name) + if !store.respond_to?(method_name) + raise Rack::Attack::MisconfiguredStoreError, "Store needs to respond to ##{method_name}" + end + end end end end diff --git a/lib/rack/attack/check.rb b/lib/rack/attack/check.rb index 21451de..2e5c64d 100644 --- a/lib/rack/attack/check.rb +++ b/lib/rack/attack/check.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack class Check @@ -7,17 +9,15 @@ module Rack @type = options.fetch(:type, nil) end - def [](req) - block[req].tap {|match| + def matched_by?(request) + block.call(request).tap do |match| if match - req.env["rack.attack.matched"] = name - req.env["rack.attack.match_type"] = type - Rack::Attack.instrument(req) + request.env["rack.attack.matched"] = name + request.env["rack.attack.match_type"] = type + Rack::Attack.instrument(request) end - } + end end - end end end - diff --git a/lib/rack/attack/fail2ban.rb b/lib/rack/attack/fail2ban.rb index 76dc59f..b43c7cb 100644 --- a/lib/rack/attack/fail2ban.rb +++ b/lib/rack/attack/fail2ban.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack class Fail2Ban @@ -27,6 +29,7 @@ module Rack end protected + def key_prefix 'fail2ban' end @@ -40,8 +43,8 @@ module Rack true end - private + def ban!(discriminator, bantime) cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime) end diff --git a/lib/rack/attack/path_normalizer.rb b/lib/rack/attack/path_normalizer.rb index 4b4171e..635f9f2 100644 --- a/lib/rack/attack/path_normalizer.rb +++ b/lib/rack/attack/path_normalizer.rb @@ -1,8 +1,9 @@ -class Rack::Attack +# frozen_string_literal: true +class Rack::Attack # When using Rack::Attack with a Rails app, developers expect the request path # to be normalized. In particular, trailing slashes are stripped. - # (See http://git.io/v0rrR for implementation.) + # (See https://git.io/v0rrR for implementation.) # # Look for an ActionDispatch utility class that Rails folks would expect # to normalize request paths. If unavailable, use a fallback class that @@ -15,10 +16,9 @@ class Rack::Attack end PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils) - # For Rails apps - ::ActionDispatch::Journey::Router::Utils - else - FallbackPathNormalizer - end - + # For Rails apps + ::ActionDispatch::Journey::Router::Utils + else + FallbackPathNormalizer + end end diff --git a/lib/rack/attack/request.rb b/lib/rack/attack/request.rb index ee05f89..1cac479 100644 --- a/lib/rack/attack/request.rb +++ b/lib/rack/attack/request.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # Rack::Attack::Request is the same as ::Rack::Request by default. # # This is a safe place to add custom helper methods to the request object diff --git a/lib/rack/attack/safelist.rb b/lib/rack/attack/safelist.rb index 748d422..d335be2 100644 --- a/lib/rack/attack/safelist.rb +++ b/lib/rack/attack/safelist.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack class Safelist < Check @@ -5,7 +7,6 @@ module Rack super @type = :safelist end - end end end diff --git a/lib/rack/attack/store_proxy.rb b/lib/rack/attack/store_proxy.rb index d41ddac..2754467 100644 --- a/lib/rack/attack/store_proxy.rb +++ b/lib/rack/attack/store_proxy.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module Rack class Attack module StoreProxy - PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy].freeze + PROXIES = [DalliProxy, MemCacheProxy, RedisProxy, RedisStoreProxy, RedisCacheStoreProxy].freeze - ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore']).freeze - ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Redis', 'Dalli::Client', 'MemCache']).freeze + ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze + ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze def self.build(store) client = unwrap_active_support_stores(store) @@ -12,8 +14,6 @@ module Rack klass ? klass.new(client) : client end - - private def self.unwrap_active_support_stores(store) # ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry, # so use the raw Redis::Store instead. diff --git a/lib/rack/attack/store_proxy/dalli_proxy.rb b/lib/rack/attack/store_proxy/dalli_proxy.rb index 703f2d6..f3fbc6c 100644 --- a/lib/rack/attack/store_proxy/dalli_proxy.rb +++ b/lib/rack/attack/store_proxy/dalli_proxy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'delegate' module Rack @@ -28,14 +30,14 @@ module Rack rescue Dalli::DalliError end - def write(key, value, options={}) + def write(key, value, options = {}) with do |client| client.set(key, value, options.fetch(:expires_in, 0), raw: true) end rescue Dalli::DalliError end - def increment(key, amount, options={}) + def increment(key, amount, options = {}) with do |client| client.incr(key, amount, options.fetch(:expires_in, 0), amount) end @@ -58,7 +60,6 @@ module Rack end end end - end end end diff --git a/lib/rack/attack/store_proxy/mem_cache_proxy.rb b/lib/rack/attack/store_proxy/mem_cache_proxy.rb index 098e048..8ed6583 100644 --- a/lib/rack/attack/store_proxy/mem_cache_proxy.rb +++ b/lib/rack/attack/store_proxy/mem_cache_proxy.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack module StoreProxy @@ -14,21 +16,21 @@ module Rack def read(key) # Second argument: reading raw value get(key, true) - rescue MemCache::MemCacheError + rescue MemCache::MemCacheError end - def write(key, value, options={}) + def write(key, value, options = {}) # Third argument: writing raw value set(key, value, options.fetch(:expires_in, 0), true) rescue MemCache::MemCacheError end - def increment(key, amount, options={}) + def increment(key, amount, _options = {}) incr(key, amount) rescue MemCache::MemCacheError end - def delete(key, options={}) + def delete(key, _options = {}) with do |client| client.delete(key) end @@ -44,7 +46,6 @@ module Rack end end end - end end end diff --git a/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb new file mode 100644 index 0000000..c7feaa7 --- /dev/null +++ b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'delegate' + +module Rack + class Attack + module StoreProxy + class RedisCacheStoreProxy < SimpleDelegator + def self.handle?(store) + defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) && store.is_a?(::ActiveSupport::Cache::RedisCacheStore) + end + + def increment(name, amount = 1, options = {}) + # RedisCacheStore#increment ignores options[:expires_in]. + # + # So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize + # the counter. After that we continue using the original RedisCacheStore#increment. + if options[:expires_in] && !read(name) + write(name, amount, options) + + amount + else + super + end + end + + def read(name, options = {}) + super(name, options.merge!(raw: true)) + end + + def write(name, value, options = {}) + super(name, value, options.merge!(raw: true)) + end + end + end + end +end diff --git a/lib/rack/attack/store_proxy/redis_store_proxy.rb b/lib/rack/attack/store_proxy/redis_store_proxy.rb index c980369..a7ac81a 100644 --- a/lib/rack/attack/store_proxy/redis_store_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_store_proxy.rb @@ -1,15 +1,21 @@ +# frozen_string_literal: true + require 'delegate' module Rack class Attack module StoreProxy class RedisStoreProxy < SimpleDelegator - def self.handle?(store) - defined?(::Redis::Store) && store.is_a?(::Redis::Store) + def initialize(*args) + if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3") + warn 'RackAttack requires Redis gem >= 3.0.0.' + end + + super(*args) end - def initialize(store) - super(store) + def self.handle?(store) + defined?(::Redis::Store) && store.is_a?(::Redis::Store) end def read(key) @@ -17,7 +23,7 @@ module Rack rescue Redis::BaseError end - def write(key, value, options={}) + def write(key, value, options = {}) if (expires_in = options[:expires_in]) setex(key, expires_in, value, raw: true) else @@ -26,7 +32,7 @@ module Rack rescue Redis::BaseError end - def increment(key, amount, options={}) + def increment(key, amount, options = {}) count = nil pipelined do @@ -38,7 +44,7 @@ module Rack rescue Redis::BaseError end - def delete(key, options={}) + def delete(key, _options = {}) del(key) rescue Redis::BaseError end diff --git a/lib/rack/attack/throttle.rb b/lib/rack/attack/throttle.rb index 19a6519..e834fc0 100644 --- a/lib/rack/attack/throttle.rb +++ b/lib/rack/attack/throttle.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Rack class Attack class Throttle @@ -18,29 +20,32 @@ module Rack Rack::Attack.cache end - def [](req) - discriminator = block[req] + def matched_by?(request) + discriminator = block.call(request) return false unless discriminator - current_period = period.respond_to?(:call) ? period.call(req) : period - current_limit = limit.respond_to?(:call) ? limit.call(req) : limit + current_period = period.respond_to?(:call) ? period.call(request) : period + current_limit = limit.respond_to?(:call) ? limit.call(request) : limit key = "#{name}:#{discriminator}" count = cache.count(key, current_period) + epoch_time = cache.last_epoch_time data = { :count => count, :period => current_period, - :limit => current_limit + :limit => current_limit, + :epoch_time => epoch_time } - (req.env['rack.attack.throttle_data'] ||= {})[name] = data + + (request.env['rack.attack.throttle_data'] ||= {})[name] = data (count > current_limit).tap do |throttled| if throttled - req.env['rack.attack.matched'] = name - req.env['rack.attack.match_discriminator'] = discriminator - req.env['rack.attack.match_type'] = type - req.env['rack.attack.match_data'] = data - Rack::Attack.instrument(req) + request.env['rack.attack.matched'] = name + request.env['rack.attack.match_discriminator'] = discriminator + request.env['rack.attack.match_type'] = type + request.env['rack.attack.match_data'] = data + Rack::Attack.instrument(request) end end end diff --git a/lib/rack/attack/track.rb b/lib/rack/attack/track.rb index e0039e3..dba16ca 100644 --- a/lib/rack/attack/track.rb +++ b/lib/rack/attack/track.rb @@ -1,8 +1,8 @@ +# frozen_string_literal: true + module Rack class Attack class Track - extend Forwardable - attr_reader :filter def initialize(name, options = {}, block) @@ -15,7 +15,9 @@ module Rack end end - def_delegator :@filter, :[] + def matched_by?(request) + filter.matched_by?(request) + end end end end diff --git a/lib/rack/attack/version.rb b/lib/rack/attack/version.rb index 06f4df7..07a1d57 100644 --- a/lib/rack/attack/version.rb +++ b/lib/rack/attack/version.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module Rack class Attack - VERSION = '5.0.1' + VERSION = '5.3.2' end end diff --git a/rack-attack.gemspec b/rack-attack.gemspec index 1fcdd42..e5828f4 100644 --- a/rack-attack.gemspec +++ b/rack-attack.gemspec @@ -1,4 +1,6 @@ # -*- encoding: utf-8 -*- +# frozen_string_literal: true + lib = File.expand_path('../lib/', __FILE__) $:.unshift lib unless $:.include?(lib) @@ -14,23 +16,38 @@ Gem::Specification.new do |s| s.email = "aaron@ktheory.com" s.files = Dir.glob("{bin,lib}/**/*") + %w(Rakefile README.md) - s.homepage = 'http://github.com/kickstarter/rack-attack' + s.homepage = 'https://github.com/kickstarter/rack-attack' s.rdoc_options = ["--charset=UTF-8"] s.require_paths = ["lib"] s.summary = %q{Block & throttle abusive requests} s.test_files = Dir.glob("spec/**/*") - s.required_ruby_version = '>= 2.0.0' + s.metadata = { + "bug_tracker_uri" => "https://github.com/kickstarter/rack-attack/issues", + "changelog_uri" => "https://github.com/kickstarter/rack-attack/blob/master/CHANGELOG.md", + "source_code_uri" => "https://github.com/kickstarter/rack-attack" + } - s.add_dependency 'rack' - s.add_development_dependency 'minitest' - s.add_development_dependency 'rack-test' - s.add_development_dependency 'rake' - s.add_development_dependency 'appraisal' - s.add_development_dependency 'activesupport', '>= 3.0.0' - s.add_development_dependency 'actionpack', '>= 3.0.0' - s.add_development_dependency 'redis-activesupport' - s.add_development_dependency 'dalli' - s.add_development_dependency 'connection_pool' - s.add_development_dependency 'memcache-client' + s.required_ruby_version = '>= 2.3' + + s.add_runtime_dependency 'rack', ">= 1.0", "< 3" + + s.add_development_dependency 'appraisal', '~> 2.2' + s.add_development_dependency "bundler", "~> 1.16" + s.add_development_dependency 'minitest', "~> 5.11" + s.add_development_dependency "minitest-stub-const", "~> 0.6" + s.add_development_dependency 'rack-test', "~> 1.0" + s.add_development_dependency 'rake', "~> 12.3" + s.add_development_dependency "rubocop", "0.57.2" + s.add_development_dependency "timecop", "~> 0.9.1" + + # byebug only works with MRI + if RUBY_ENGINE == "ruby" + s.add_development_dependency 'byebug', '~> 10.0' + end + + # The following are potential runtime dependencies users may have, + # which rack-attack uses only for testing compatibility in test suite. + s.add_development_dependency 'actionpack', '~> 5.2' + s.add_development_dependency 'activesupport', '~> 5.2' end diff --git a/spec/acceptance/allow2ban_spec.rb b/spec/acceptance/allow2ban_spec.rb new file mode 100644 index 0000000..6de18d0 --- /dev/null +++ b/spec/acceptance/allow2ban_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "allow2ban" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + Rack::Attack.blocklist("allow2ban pentesters") do |request| + Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("scarce-resource") + end + end + end + + it "returns OK for many requests that doesn't match the filter" do + get "/" + assert_equal 200, last_response.status + + get "/" + assert_equal 200, last_response.status + end + + it "returns OK for first request that matches the filter" do + get "/scarce-resource" + assert_equal 200, last_response.status + end + + it "forbids all access after reaching maxretry limit" do + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end + + it "restores access after bantime elapsed" do + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/" + assert_equal 403, last_response.status + + Timecop.travel(60) do + get "/" + + assert_equal 200, last_response.status + end + end + + it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do + get "/scarce-resource" + assert_equal 200, last_response.status + + Timecop.travel(31) do + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/" + assert_equal 200, last_response.status + 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..cfb2282 --- /dev/null +++ b/spec/acceptance/blocking_ip_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +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, payload| + notified = true + notification_type = payload[: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_spec.rb b/spec/acceptance/blocking_spec.rb new file mode 100644 index 0000000..db176c8 --- /dev/null +++ b/spec/acceptance/blocking_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#blocklist" do + before do + Rack::Attack.blocklist("block 1.2.3.4") do |request| + request.ip == "1.2.3.4" + end + end + + it "forbids request if blocklist condition is true" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + end + + it "succeeds if blocklist condition is false" do + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + end + + it "notifies when the request is blocked" do + notification_matched = nil + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notification_matched = payload[:request].env["rack.attack.matched"] + notification_type = payload[:request].env["rack.attack.match_type"] + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_nil notification_matched + assert_nil notification_type + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal "block 1.2.3.4", notification_matched + 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..1f417d7 --- /dev/null +++ b/spec/acceptance/blocking_subnet_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +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, payload| + notified = true + notification_type = payload[: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/cache_store_config_for_allow2ban_spec.rb b/spec/acceptance/cache_store_config_for_allow2ban_spec.rb new file mode 100644 index 0000000..09fdab8 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_allow2ban_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Cache store config when using allow2ban" do + before do + Rack::Attack.blocklist("allow2ban pentesters") do |request| + Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("scarce-resource") + end + end + end + + it "gives semantic error if no store was configured" do + assert_raises(Rack::Attack::MissingStoreError) do + get "/scarce-resource" + end + end + + it "gives semantic error if store is missing #read method" do + basic_store_class = Class.new do + def write(key, value) + end + + def increment(key, count, options = {}) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + + assert_equal "Store needs to respond to #read", raised_exception.message + end + + it "gives semantic error if store is missing #write method" do + basic_store_class = Class.new do + def read(key) + end + + def increment(key, count, options = {}) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + + assert_equal "Store needs to respond to #write", raised_exception.message + end + + it "gives semantic error if store is missing #increment method" do + basic_store_class = Class.new do + def read(key) + end + + def write(key, value) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + + assert_equal "Store needs to respond to #increment", raised_exception.message + end + + it "works with any object that responds to #read, #write and #increment" do + basic_store_class = Class.new do + attr_accessor :backend + + def initialize + @backend = {} + end + + def read(key) + @backend[key] + end + + def write(key, value, _options = {}) + @backend[key] = value + end + + def increment(key, _count, _options = {}) + @backend[key] ||= 0 + @backend[key] += 1 + end + end + + Rack::Attack.cache.store = basic_store_class.new + + get "/" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end +end diff --git a/spec/acceptance/cache_store_config_for_fail2ban_spec.rb b/spec/acceptance/cache_store_config_for_fail2ban_spec.rb new file mode 100644 index 0000000..8052fc1 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_fail2ban_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Cache store config when using fail2ban" do + before do + Rack::Attack.blocklist("fail2ban pentesters") do |request| + Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("private-place") + end + end + end + + it "gives semantic error if no store was configured" do + assert_raises(Rack::Attack::MissingStoreError) do + get "/private-place" + end + end + + it "gives semantic error if store is missing #read method" do + basic_store_class = Class.new do + def write(key, value) + end + + def increment(key, count, options = {}) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + + assert_equal "Store needs to respond to #read", raised_exception.message + end + + it "gives semantic error if store is missing #write method" do + basic_store_class = Class.new do + def read(key) + end + + def increment(key, count, options = {}) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + + assert_equal "Store needs to respond to #write", raised_exception.message + end + + it "gives semantic error if store is missing #increment method" do + basic_store_class = Class.new do + def read(key) + end + + def write(key, value) + end + end + + Rack::Attack.cache.store = basic_store_class.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + + assert_equal "Store needs to respond to #increment", raised_exception.message + end + + it "works with any object that responds to #read, #write and #increment" do + basic_store_class = Class.new do + attr_accessor :backend + + def initialize + @backend = {} + end + + def read(key) + @backend[key] + end + + def write(key, value, _options = {}) + @backend[key] = value + end + + def increment(key, _count, _options = {}) + @backend[key] ||= 0 + @backend[key] += 1 + end + end + + Rack::Attack.cache.store = basic_store_class.new + + get "/" + assert_equal 200, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end +end diff --git a/spec/acceptance/cache_store_config_for_throttle_spec.rb b/spec/acceptance/cache_store_config_for_throttle_spec.rb new file mode 100644 index 0000000..9be6e59 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_throttle_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Cache store config when throttling without Rails" do + before do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + end + + it "gives semantic error if no store was configured" do + assert_raises(Rack::Attack::MissingStoreError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + + it "gives semantic error if incompatible store was configured" do + Rack::Attack.cache.store = Object.new + + assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + + it "works with any object that responds to #increment" do + basic_store_class = Class.new do + attr_accessor :counts + + def initialize + @counts = {} + end + + def increment(key, _count, _options) + @counts[key] ||= 0 + @counts[key] += 1 + end + end + + Rack::Attack.cache.store = basic_store_class.new + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + end +end diff --git a/spec/acceptance/cache_store_config_with_rails_spec.rb b/spec/acceptance/cache_store_config_with_rails_spec.rb new file mode 100644 index 0000000..3d9ac22 --- /dev/null +++ b/spec/acceptance/cache_store_config_with_rails_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "minitest/stub_const" +require "ostruct" + +describe "Cache store config with Rails" do + before do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + end + + it "fails when Rails.cache is not set" do + Object.stub_const(:Rails, OpenStruct.new(cache: nil)) do + assert_raises(Rack::Attack::MissingStoreError) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + end + end + + it "works when Rails.cache is set" do + Object.stub_const(:Rails, OpenStruct.new(cache: ActiveSupport::Cache::MemoryStore.new)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + end + end +end diff --git a/spec/acceptance/customizing_blocked_response_spec.rb b/spec/acceptance/customizing_blocked_response_spec.rb new file mode 100644 index 0000000..fd830c2 --- /dev/null +++ b/spec/acceptance/customizing_blocked_response_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Customizing block responses" do + before do + Rack::Attack.blocklist("block 1.2.3.4") do |request| + request.ip == "1.2.3.4" + end + end + + it "can be customized" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + Rack::Attack.blocklisted_response = lambda do |_env| + [503, {}, ["Blocked"]] + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 503, last_response.status + assert_equal "Blocked", last_response.body + end + + it "exposes match data" do + matched = nil + match_type = nil + + Rack::Attack.blocklisted_response = lambda do |env| + matched = env['rack.attack.matched'] + match_type = env['rack.attack.match_type'] + + [503, {}, ["Blocked"]] + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal "block 1.2.3.4", matched + assert_equal :blocklist, match_type + end +end diff --git a/spec/acceptance/customizing_throttled_response_spec.rb b/spec/acceptance/customizing_throttled_response_spec.rb new file mode 100644 index 0000000..5c84979 --- /dev/null +++ b/spec/acceptance/customizing_throttled_response_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Customizing throttled response" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + end + + it "can be customized" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + + Rack::Attack.throttled_response = lambda do |_env| + [503, {}, ["Throttled"]] + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 503, last_response.status + assert_equal "Throttled", last_response.body + end + + it "exposes match data" do + matched = nil + match_type = nil + match_data = nil + match_discriminator = nil + + Rack::Attack.throttled_response = lambda do |env| + matched = env['rack.attack.matched'] + match_type = env['rack.attack.match_type'] + match_data = env['rack.attack.match_data'] + match_discriminator = env['rack.attack.match_discriminator'] + + [429, {}, ["Throttled"]] + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal "by ip", matched + assert_equal :throttle, match_type + assert_equal 60, match_data[:period] + assert_equal 1, match_data[:limit] + assert_equal 2, match_data[:count] + assert_equal "1.2.3.4", match_discriminator + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 3, match_data[:count] + end +end diff --git a/spec/acceptance/extending_request_object_spec.rb b/spec/acceptance/extending_request_object_spec.rb new file mode 100644 index 0000000..a4ea1a6 --- /dev/null +++ b/spec/acceptance/extending_request_object_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Extending the request object" do + before do + class Rack::Attack::Request + def authorized? + env["APIKey"] == "private-secret" + end + end + + Rack::Attack.blocklist("unauthorized requests") do |request| + !request.authorized? + end + end + + # We don't want the extension to leak to other test cases + after do + class Rack::Attack::Request + remove_method :authorized? + end + end + + it "forbids request if blocklist condition is true" do + get "/" + + assert_equal 403, last_response.status + end + + it "succeeds if blocklist condition is false" do + get "/", {}, "APIKey" => "private-secret" + + assert_equal 200, last_response.status + end +end diff --git a/spec/acceptance/fail2ban_spec.rb b/spec/acceptance/fail2ban_spec.rb new file mode 100644 index 0000000..fde67f9 --- /dev/null +++ b/spec/acceptance/fail2ban_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "fail2ban" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + Rack::Attack.blocklist("fail2ban pentesters") do |request| + Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("private-place") + end + end + end + + it "returns OK for many requests to non filtered path" do + get "/" + assert_equal 200, last_response.status + + get "/" + assert_equal 200, last_response.status + end + + it "forbids access to private path" do + get "/private-place" + assert_equal 403, last_response.status + end + + it "returns OK for non filtered path if yet not reached maxretry limit" do + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 200, last_response.status + end + + it "forbids all access after reaching maxretry limit" do + get "/private-place" + assert_equal 403, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end + + it "restores access after bantime elapsed" do + get "/private-place" + assert_equal 403, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + + Timecop.travel(60) do + get "/" + + assert_equal 200, last_response.status + end + end + + it "does not forbid all access if maxrety condition is met but not within the findtime timespan" do + get "/private-place" + assert_equal 403, last_response.status + + Timecop.travel(31) do + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 200, last_response.status + end + end +end diff --git a/spec/acceptance/safelisting_ip_spec.rb b/spec/acceptance/safelisting_ip_spec.rb new file mode 100644 index 0000000..bdf7d67 --- /dev/null +++ b/spec/acceptance/safelisting_ip_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +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, payload| + notification_type = payload[: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_spec.rb b/spec/acceptance/safelisting_spec.rb new file mode 100644 index 0000000..dc4d82d --- /dev/null +++ b/spec/acceptance/safelisting_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#safelist" do + before do + Rack::Attack.blocklist("block 1.2.3.4") do |request| + request.ip == "1.2.3.4" + end + + Rack::Attack.safelist("safe path") do |request| + request.path == "/safe_space" + end + end + + it "forbids request if blocklist condition is true and safelist is false" do + get "/", {}, "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" => "5.6.7.8" + + assert_equal 200, last_response.status + end + + it "succeeds request if blocklist condition is false and safelist is true" do + get "/safe_space", {}, "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 "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + + it "notifies when the request is safe" do + notification_matched = nil + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notification_matched = payload[:request].env["rack.attack.matched"] + notification_type = payload[:request].env["rack.attack.match_type"] + end + + get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + assert_equal "safe path", notification_matched + 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..50d99f6 --- /dev/null +++ b/spec/acceptance/safelisting_subnet_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +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, payload| + notification_type = payload[: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 diff --git a/spec/acceptance/stores/active_support_dalli_store_spec.rb b/spec/acceptance/stores/active_support_dalli_store_spec.rb new file mode 100644 index 0000000..45ecf3d --- /dev/null +++ b/spec/acceptance/stores/active_support_dalli_store_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) + require_relative "../../support/cache_store_helper" + require "active_support/cache/dalli_store" + require "timecop" + + describe "ActiveSupport::Cache::DalliStore as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::DalliStore.new + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.fetch(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.fetch(key) + end + end +end diff --git a/spec/acceptance/stores/active_support_mem_cache_store_spec.rb b/spec/acceptance/stores/active_support_mem_cache_store_spec.rb new file mode 100644 index 0000000..86de47c --- /dev/null +++ b/spec/acceptance/stores/active_support_mem_cache_store_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) + require_relative "../../support/cache_store_helper" + require "timecop" + + describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new + end + + after do + Rack::Attack.cache.store.flush_all + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.get(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.get(key) + end + end +end diff --git a/spec/acceptance/stores/active_support_memory_store_spec.rb b/spec/acceptance/stores/active_support_memory_store_spec.rb new file mode 100644 index 0000000..8657aa2 --- /dev/null +++ b/spec/acceptance/stores/active_support_memory_store_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require_relative "../../support/cache_store_helper" + +require "timecop" + +describe "ActiveSupport::Cache::MemoryStore as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.fetch(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.fetch(key) + end +end diff --git a/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb b/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb new file mode 100644 index 0000000..13da998 --- /dev/null +++ b/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::ConnectionPool) && defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) + require_relative "../../support/cache_store_helper" + require "timecop" + + describe "ActiveSupport::Cache::RedisCacheStore (pooled) as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(pool_size: 2) + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.fetch(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.fetch(key) + end + end +end diff --git a/spec/acceptance/stores/active_support_redis_cache_store_spec.rb b/spec/acceptance/stores/active_support_redis_cache_store_spec.rb new file mode 100644 index 0000000..4303a3f --- /dev/null +++ b/spec/acceptance/stores/active_support_redis_cache_store_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisCacheStore) + require_relative "../../support/cache_store_helper" + require "timecop" + + describe "ActiveSupport::Cache::RedisCacheStore as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + # puts key + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.fetch(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.fetch(key) + end + end +end diff --git a/spec/acceptance/stores/active_support_redis_store_spec.rb b/spec/acceptance/stores/active_support_redis_store_spec.rb new file mode 100644 index 0000000..9aa7d08 --- /dev/null +++ b/spec/acceptance/stores/active_support_redis_store_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::ActiveSupport::Cache::RedisStore) + require_relative "../../support/cache_store_helper" + require "timecop" + + describe "ActiveSupport::Cache::RedisStore as a cache backend" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new + end + + after do + Rack::Attack.cache.store.flushdb + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.read(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.read(key) + end + end +end diff --git a/spec/acceptance/stores/connection_pool_dalli_client_spec.rb b/spec/acceptance/stores/connection_pool_dalli_client_spec.rb new file mode 100644 index 0000000..1ccf5d2 --- /dev/null +++ b/spec/acceptance/stores/connection_pool_dalli_client_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) && defined?(::ConnectionPool) + require_relative "../../support/cache_store_helper" + require "connection_pool" + require "dalli" + require "timecop" + + describe "ConnectionPool with Dalli::Client as a cache backend" do + before do + Rack::Attack.cache.store = ConnectionPool.new { Dalli::Client.new } + end + + after do + Rack::Attack.cache.store.with { |client| client.flush_all } + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert(Rack::Attack.cache.store.with { |client| client.fetch(key) }) + + sleep 2.1 + + assert_nil(Rack::Attack.cache.store.with { |client| client.fetch(key) }) + end + end +end diff --git a/spec/acceptance/stores/dalli_client_spec.rb b/spec/acceptance/stores/dalli_client_spec.rb new file mode 100644 index 0000000..dedd9ab --- /dev/null +++ b/spec/acceptance/stores/dalli_client_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) + require_relative "../../support/cache_store_helper" + require "dalli" + require "timecop" + + describe "Dalli::Client as a cache backend" do + before do + Rack::Attack.cache.store = Dalli::Client.new + end + + after do + Rack::Attack.cache.store.flush_all + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.fetch(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.fetch(key) + end + end +end diff --git a/spec/acceptance/stores/redis_store_spec.rb b/spec/acceptance/stores/redis_store_spec.rb new file mode 100644 index 0000000..82898e6 --- /dev/null +++ b/spec/acceptance/stores/redis_store_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require_relative "../../support/cache_store_helper" + +if defined?(::Redis::Store) + require "timecop" + + describe "ActiveSupport::Cache::RedisStore as a cache backend" do + before do + Rack::Attack.cache.store = ::Redis::Store.new + end + + after do + Rack::Attack.cache.store.flushdb + end + + it_works_for_cache_backed_features + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + # Freeze time during these statement to be sure that the key used by rack attack is the same + # we pre-calculate in local variable `key` + Timecop.freeze do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert Rack::Attack.cache.store.read(key) + + sleep 2.1 + + assert_nil Rack::Attack.cache.store.read(key) + end + end +end diff --git a/spec/acceptance/throttling_spec.rb b/spec/acceptance/throttling_spec.rb new file mode 100644 index 0000000..719def8 --- /dev/null +++ b/spec/acceptance/throttling_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "#throttle" do + before do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + end + + it "allows one request per minute by IP" do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + assert_equal "60", last_response.headers["Retry-After"] + assert_equal "Retry later\n", last_response.body + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + + Timecop.travel(60) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + end + end + + it "supports limit to be dynamic" do + # Could be used to have different rate limits for authorized + # vs general requests + limit_proc = lambda do |request| + if request.env["X-APIKey"] == "private-secret" + 2 + else + 1 + end + end + + Rack::Attack.throttle("by ip", limit: limit_proc, period: 60) do |request| + request.ip + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 429, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 429, last_response.status + end + + it "supports period to be dynamic" do + # Could be used to have different rate limits for authorized + # vs general requests + period_proc = lambda do |request| + if request.env["X-APIKey"] == "private-secret" + 10 + else + 30 + end + end + + Rack::Attack.throttle("by ip", limit: 1, period: period_proc) do |request| + request.ip + end + + # Using Time#at to align to start/end of periods exactly + # to achieve consistenty in different test runs + + Timecop.travel(Time.at(0)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 429, last_response.status + end + + Timecop.travel(Time.at(10)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 429, last_response.status + end + + Timecop.travel(Time.at(30)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 200, last_response.status + end + + Timecop.travel(Time.at(0)) do + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 429, last_response.status + end + + Timecop.travel(Time.at(10)) do + get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" + assert_equal 200, last_response.status + end + end + + it "notifies when the request is throttled" do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + + notification_matched = nil + notification_type = nil + notification_data = nil + notification_discriminator = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notification_matched = payload[:request].env["rack.attack.matched"] + notification_type = payload[:request].env["rack.attack.match_type"] + notification_data = payload[:request].env['rack.attack.match_data'] + notification_discriminator = payload[:request].env['rack.attack.match_discriminator'] + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + assert_nil notification_matched + assert_nil notification_type + assert_nil notification_data + assert_nil notification_discriminator + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + assert_nil notification_matched + assert_nil notification_type + assert_nil notification_data + assert_nil notification_discriminator + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + assert_equal "by ip", notification_matched + assert_equal :throttle, notification_type + assert_equal 60, notification_data[:period] + assert_equal 1, notification_data[:limit] + assert_equal 2, notification_data[:count] + assert_equal "1.2.3.4", notification_discriminator + end +end diff --git a/spec/acceptance/track_spec.rb b/spec/acceptance/track_spec.rb new file mode 100644 index 0000000..5f72db8 --- /dev/null +++ b/spec/acceptance/track_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#track" do + it "notifies when track block returns true" do + Rack::Attack.track("ip 1.2.3.4") do |request| + request.ip == "1.2.3.4" + end + + notification_matched = nil + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notification_matched = payload[:request].env["rack.attack.matched"] + notification_type = payload[:request].env["rack.attack.match_type"] + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_nil notification_matched + assert_nil notification_type + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal "ip 1.2.3.4", notification_matched + assert_equal :track, notification_type + end +end diff --git a/spec/acceptance/track_throttle_spec.rb b/spec/acceptance/track_throttle_spec.rb new file mode 100644 index 0000000..1d2c5ab --- /dev/null +++ b/spec/acceptance/track_throttle_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "#track with throttle-ish options" do + it "notifies when throttle goes over the limit without actually throttling requests" do + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + Rack::Attack.track("by ip", limit: 1, period: 60) do |request| + request.ip + end + + notification_matched = nil + notification_type = nil + + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notification_matched = payload[:request].env["rack.attack.matched"] + notification_type = payload[:request].env["rack.attack.match_type"] + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_nil notification_matched + assert_nil notification_type + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_nil notification_matched + assert_nil notification_type + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal "by ip", notification_matched + assert_equal :track, notification_type + + assert_equal 200, last_response.status + + Timecop.travel(60) do + notification_matched = nil + notification_type = nil + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_nil notification_matched + assert_nil notification_type + + assert_equal 200, last_response.status + end + end +end diff --git a/spec/allow2ban_spec.rb b/spec/allow2ban_spec.rb index 9eeae9c..074a231 100644 --- a/spec/allow2ban_spec.rb +++ b/spec/allow2ban_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack.Allow2Ban' do @@ -7,10 +9,10 @@ describe 'Rack::Attack.Allow2Ban' do @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - @f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2} + @f2b_options = { :bantime => @bantime, :findtime => @findtime, :maxretry => 2 } Rack::Attack.blocklist('pentest') do |req| - Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/} + Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ } end end @@ -31,7 +33,7 @@ describe 'Rack::Attack.Allow2Ban' do end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" @cache.store.read(key).must_equal 1 end @@ -53,7 +55,7 @@ describe 'Rack::Attack.Allow2Ban' do end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end @@ -89,7 +91,7 @@ describe 'Rack::Attack.Allow2Ban' do end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end @@ -109,7 +111,7 @@ describe 'Rack::Attack.Allow2Ban' do end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end diff --git a/spec/fail2ban_spec.rb b/spec/fail2ban_spec.rb index 9d4bcc1..91242f7 100644 --- a/spec/fail2ban_spec.rb +++ b/spec/fail2ban_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack.Fail2Ban' do @@ -7,10 +9,10 @@ describe 'Rack::Attack.Fail2Ban' do @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - @f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2} + @f2b_options = { :bantime => @bantime, :findtime => @findtime, :maxretry => 2 } Rack::Attack.blocklist('pentest') do |req| - Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/} + Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ } end end @@ -31,7 +33,7 @@ describe 'Rack::Attack.Fail2Ban' do end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" @cache.store.read(key).must_equal 1 end @@ -53,7 +55,7 @@ describe 'Rack::Attack.Fail2Ban' do end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end @@ -75,7 +77,7 @@ describe 'Rack::Attack.Fail2Ban' do end it 'resets fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" assert_nil @cache.store.read(key) end @@ -110,7 +112,7 @@ describe 'Rack::Attack.Fail2Ban' do end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end @@ -130,7 +132,7 @@ describe 'Rack::Attack.Fail2Ban' do end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" @cache.store.read(key).must_equal 2 end diff --git a/spec/integration/offline_spec.rb b/spec/integration/offline_spec.rb index f47fb31..1f2cbfa 100644 --- a/spec/integration/offline_spec.rb +++ b/spec/integration/offline_spec.rb @@ -1,6 +1,6 @@ +# frozen_string_literal: true + require 'active_support/cache' -require 'redis-activesupport' -require 'dalli' require_relative '../spec_helper' OfflineExamples = Minitest::SharedExamples.new do @@ -17,27 +17,31 @@ OfflineExamples = Minitest::SharedExamples.new do end end -describe 'when Redis is offline' do - include OfflineExamples +if defined?(::ActiveSupport::Cache::RedisStore) + describe 'when Redis is offline' do + include OfflineExamples - before { - @cache = Rack::Attack::Cache.new - # Use presumably unused port for Redis client - @cache.store = ActiveSupport::Cache::RedisStore.new(:host => '127.0.0.1', :port => 3333) - } + before do + @cache = Rack::Attack::Cache.new + # Use presumably unused port for Redis client + @cache.store = ActiveSupport::Cache::RedisStore.new(:host => '127.0.0.1', :port => 3333) + end + end end -describe 'when Memcached is offline' do - include OfflineExamples +if defined?(::Dalli) + describe 'when Memcached is offline' do + include OfflineExamples - before { - Dalli.logger.level = Logger::FATAL + before do + Dalli.logger.level = Logger::FATAL - @cache = Rack::Attack::Cache.new - @cache.store = Dalli::Client.new('127.0.0.1:22122') - } + @cache = Rack::Attack::Cache.new + @cache.store = Dalli::Client.new('127.0.0.1:22122') + end - after { - Dalli.logger.level = Logger::INFO - } + after do + Dalli.logger.level = Logger::INFO + end + end end diff --git a/spec/integration/rack_attack_cache_spec.rb b/spec/integration/rack_attack_cache_spec.rb deleted file mode 100644 index 354e4ba..0000000 --- a/spec/integration/rack_attack_cache_spec.rb +++ /dev/null @@ -1,122 +0,0 @@ -require_relative '../spec_helper' - -describe Rack::Attack::Cache do - # A convenience method for deleting a key from cache. - # Slightly differnet than @cache.delete, which adds a prefix. - def delete(key) - if @cache.store.respond_to?(:delete) - @cache.store.delete(key) - else - @cache.store.del(key) - end - end - - def sleep_until_expired - sleep(@expires_in * 1.1) # Add 10% to reduce errors - end - - require 'active_support/cache/dalli_store' - require 'active_support/cache/mem_cache_store' - require 'active_support/cache/redis_store' - require 'connection_pool' - - cache_stores = [ - ActiveSupport::Cache::MemoryStore.new, - ActiveSupport::Cache::DalliStore.new("127.0.0.1"), - ActiveSupport::Cache::RedisStore.new("127.0.0.1"), - ActiveSupport::Cache::MemCacheStore.new("127.0.0.1"), - Dalli::Client.new, - ConnectionPool.new { Dalli::Client.new }, - Redis::Store.new, - Redis.new - ] - - cache_stores.each do |store| - store = Rack::Attack::StoreProxy.build(store) - - describe "with #{store.class}" do - before { - @cache = Rack::Attack::Cache.new - @key = "rack::attack:cache-test-key" - @expires_in = 1 - @cache.store = store - delete(@key) - } - - after { delete(@key) } - - describe "do_count once" do - it "should be 1" do - @cache.send(:do_count, @key, @expires_in).must_equal 1 - end - end - - describe "do_count twice" do - it "must be 2" do - @cache.send(:do_count, @key, @expires_in) - @cache.send(:do_count, @key, @expires_in).must_equal 2 - end - end - - describe "do_count after expires_in" do - it "must be 1" do - @cache.send(:do_count, @key, @expires_in) - sleep_until_expired - @cache.send(:do_count, @key, @expires_in).must_equal 1 - end - end - - describe "write" do - it "should write a value to the store with prefix" do - @cache.write("cache-test-key", "foobar", 1) - store.read(@key).must_equal "foobar" - end - end - - describe "write after expiry" do - it "must not have a value" do - @cache.write("cache-test-key", "foobar", @expires_in) - sleep_until_expired - store.read(@key).must_be :nil? - end - end - - describe "read" do - it "must read the value with a prefix" do - store.write(@key, "foobar", :expires_in => @expires_in) - @cache.read("cache-test-key").must_equal "foobar" - end - end - - describe "delete" do - it "must delete the value" do - store.write(@key, "foobar", :expires_in => @expires_in) - @cache.read('cache-test-key').must_equal "foobar" - store.delete(@key) - assert_nil @cache.read('cache-test-key') - end - end - - describe "cache#delete" do - it "must delete the value" do - @cache.write("cache-test-key", "foobar", 1) - store.read(@key).must_equal "foobar" - @cache.delete('cache-test-key') - store.read(@key).must_be :nil? - end - end - - describe "reset_count" do - it "must delete the value" do - period = 1.minute - unprefixed_key = 'cache-test-key' - @cache.count(unprefixed_key, period) - period_key, _ = @cache.send(:key_and_expiry, 'cache-test-key', period) - store.read(period_key).to_i.must_equal 1 - @cache.reset_count(unprefixed_key, period) - assert_nil store.read(period_key) - end - end - end - end -end diff --git a/spec/rack_attack_dalli_proxy_spec.rb b/spec/rack_attack_dalli_proxy_spec.rb index 50a2b9c..7eb23f0 100644 --- a/spec/rack_attack_dalli_proxy_spec.rb +++ b/spec/rack_attack_dalli_proxy_spec.rb @@ -1,10 +1,10 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe Rack::Attack::StoreProxy::DalliProxy do - it 'should stub Dalli::Client#with on older clients' do proxy = Rack::Attack::StoreProxy::DalliProxy.new(Class.new) proxy.with {} # will not raise an error end - end diff --git a/spec/rack_attack_instrumentation_spec.rb b/spec/rack_attack_instrumentation_spec.rb new file mode 100644 index 0000000..1bd51a3 --- /dev/null +++ b/spec/rack_attack_instrumentation_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# ActiveSupport::Subscribers added in ~> 4.0.2.0 +if ActiveSupport::VERSION::MAJOR > 3 + require_relative 'spec_helper' + require 'active_support/subscriber' + class CustomSubscriber < ActiveSupport::Subscriber + def rack(event) + # Do virtually (but not) nothing. + event.inspect + end + end + + describe 'Rack::Attack.instrument' do + before do + @period = 60 # Use a long period; failures due to cache key rotation less likely + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |req| req.ip } + end + + describe "with throttling" do + before do + ActiveSupport::Notifications.stub(:notifier, ActiveSupport::Notifications::Fanout.new) do + CustomSubscriber.attach_to("attack") + 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } + end + end + it 'should instrument without error' do + last_response.status.must_equal 429 + end + end + end +end diff --git a/spec/rack_attack_path_normalizer_spec.rb b/spec/rack_attack_path_normalizer_spec.rb index 1c5b66e..961491d 100644 --- a/spec/rack_attack_path_normalizer_spec.rb +++ b/spec/rack_attack_path_normalizer_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe Rack::Attack::PathNormalizer do diff --git a/spec/rack_attack_request_spec.rb b/spec/rack_attack_request_spec.rb index cc617ae..8d4d27f 100644 --- a/spec/rack_attack_request_spec.rb +++ b/spec/rack_attack_request_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack' do @@ -14,6 +16,6 @@ describe 'Rack::Attack' do end end - allow_ok_requests + it_allows_ok_requests end end diff --git a/spec/rack_attack_spec.rb b/spec/rack_attack_spec.rb index 6addd16..d99e89d 100644 --- a/spec/rack_attack_spec.rb +++ b/spec/rack_attack_spec.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack' do - allow_ok_requests + it_allows_ok_requests describe 'normalizing paths' do before do - Rack::Attack.blocklist("banned_path") {|req| req.path == '/foo' } + Rack::Attack.blocklist("banned_path") { |req| req.path == '/foo' } end it 'blocks requests with trailing slash' do @@ -17,20 +19,13 @@ describe 'Rack::Attack' do describe 'blocklist' do before do @bad_ip = '1.2.3.4' - Rack::Attack.blocklist("ip #{@bad_ip}") {|req| req.ip == @bad_ip } + Rack::Attack.blocklist("ip #{@bad_ip}") { |req| req.ip == @bad_ip } end it('has a blocklist') { Rack::Attack.blocklists.key?("ip #{@bad_ip}").must_equal true } - it('has a blacklist with a deprication warning') { - _, stderror = capture_io do - Rack::Attack.blacklists.key?("ip #{@bad_ip}").must_equal true - end - assert_match "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead.", stderror - } - describe "a bad request" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip } @@ -44,24 +39,17 @@ describe 'Rack::Attack' do last_request.env['rack.attack.match_type'].must_equal :blocklist end - allow_ok_requests + it_allows_ok_requests end describe "and safelist" do before do @good_ua = 'GoodUA' - Rack::Attack.safelist("good ua") {|req| req.user_agent == @good_ua } + Rack::Attack.safelist("good ua") { |req| req.user_agent == @good_ua } end it('has a safelist') { Rack::Attack.safelists.key?("good ua") } - it('has a whitelist with a deprication warning') { - _, stderror = capture_io do - Rack::Attack.whitelists.key?("good ua") - end - assert_match "[DEPRECATION] 'Rack::Attack.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 } @@ -80,13 +68,6 @@ describe 'Rack::Attack' do it 'should exist' do Rack::Attack.blocklisted_response.must_respond_to :call end - - it 'should give a deprication warning for blacklisted_response' do - _, stderror = capture_io do - Rack::Attack.blacklisted_response - end - assert_match "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead.", stderror - end end describe '#throttled_response' do diff --git a/spec/rack_attack_throttle_spec.rb b/spec/rack_attack_throttle_spec.rb index 28ab26c..616fea0 100644 --- a/spec/rack_attack_throttle_spec.rb +++ b/spec/rack_attack_throttle_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack.throttle' do @@ -9,18 +11,18 @@ describe 'Rack::Attack.throttle' do it('should have a throttle') { Rack::Attack.throttles.key?('ip/sec') } - allow_ok_requests + it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do - key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" Rack::Attack.cache.store.read(key).must_equal 1 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } + data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i } last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data end end @@ -37,7 +39,7 @@ describe 'Rack::Attack.throttle' do it 'should tag the env' do last_request.env['rack.attack.matched'].must_equal 'ip/sec' last_request.env['rack.attack.match_type'].must_equal :throttle - last_request.env['rack.attack.match_data'].must_equal({:count => 2, :limit => 1, :period => @period}) + last_request.env['rack.attack.match_data'].must_equal(:count => 2, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i) last_request.env['rack.attack.match_discriminator'].must_equal('1.2.3.4') end @@ -51,21 +53,21 @@ describe 'Rack::Attack.throttle with limit as proc' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - Rack::Attack.throttle('ip/sec', :limit => lambda { |req| 1 }, :period => @period) { |req| req.ip } + Rack::Attack.throttle('ip/sec', :limit => lambda { |_req| 1 }, :period => @period) { |req| req.ip } end - allow_ok_requests + it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do - key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" Rack::Attack.cache.store.read(key).must_equal 1 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } + data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i } last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data end end @@ -75,21 +77,21 @@ describe 'Rack::Attack.throttle with period as proc' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - Rack::Attack.throttle('ip/sec', :limit => lambda { |req| 1 }, :period => lambda { |req| @period }) { |req| req.ip } + Rack::Attack.throttle('ip/sec', :limit => lambda { |_req| 1 }, :period => lambda { |_req| @period }) { |req| req.ip } end - allow_ok_requests + it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do - key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" Rack::Attack.cache.store.read(key).must_equal 1 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } + data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i } last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data end end @@ -102,13 +104,13 @@ describe 'Rack::Attack.throttle with block retuning nil' do Rack::Attack.throttle('ip/sec', :limit => 1, :period => @period) { |_| nil } end - allow_ok_requests + it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should not set the counter' do - key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4" + key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" assert_nil Rack::Attack.cache.store.read(key) end diff --git a/spec/rack_attack_track_spec.rb b/spec/rack_attack_track_spec.rb index 6bd38f3..39c6ab9 100644 --- a/spec/rack_attack_track_spec.rb +++ b/spec/rack_attack_track_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack.track' do @@ -16,10 +18,10 @@ describe 'Rack::Attack.track' do end before do - Rack::Attack.track("everything"){ |req| true } + Rack::Attack.track("everything") { |_req| true } end - allow_ok_requests + it_allows_ok_requests it "should tag the env" do get '/' @@ -31,9 +33,9 @@ describe 'Rack::Attack.track' do before do Counter.reset # A second track - Rack::Attack.track("homepage"){ |req| req.path == "/"} + Rack::Attack.track("homepage") { |req| req.path == "/" } - ActiveSupport::Notifications.subscribe("rack.attack") do |*args| + ActiveSupport::Notifications.subscribe("rack.attack") do |*_args| Counter.incr end @@ -47,15 +49,15 @@ describe 'Rack::Attack.track' do describe "without limit and period options" do it "should assign the track filter to a Check instance" do - tracker = Rack::Attack.track("homepage") { |req| req.path == "/"} - tracker.filter.class.must_equal Rack::Attack::Check + track = Rack::Attack.track("homepage") { |req| req.path == "/" } + track.filter.class.must_equal Rack::Attack::Check end end describe "with limit and period options" do it "should assign the track filter to a Throttle instance" do - tracker = Rack::Attack.track("homepage", :limit => 10, :period => 10) { |req| req.path == "/"} - tracker.filter.class.must_equal Rack::Attack::Throttle + track = Rack::Attack.track("homepage", :limit => 10, :period => 10) { |req| req.path == "/" } + track.filter.class.must_equal Rack::Attack::Throttle end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b7d535f..b46161e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,4 +1,5 @@ -require "rubygems" +# frozen_string_literal: true + require "bundler/setup" require "minitest/autorun" @@ -9,26 +10,51 @@ require 'action_dispatch' require "rack/attack" -begin - require 'pry' -rescue LoadError - #nothing to do here +if RUBY_ENGINE == "ruby" + require "byebug" end -class MiniTest::Spec +def safe_require(name) + begin + require name + rescue LoadError + end +end +safe_require "connection_pool" +safe_require "dalli" +safe_require "redis" +safe_require "redis-activesupport" +safe_require "redis-store" + +class MiniTest::Spec include Rack::Test::Methods - after { Rack::Attack.clear! } - - def app - Rack::Builder.new { - use Rack::Attack - run lambda {|env| [200, {}, ['Hello World']]} - }.to_app + before do + @_original_throttled_response = Rack::Attack.throttled_response + @_original_blocklisted_response = Rack::Attack.blocklisted_response end - def self.allow_ok_requests + after do + Rack::Attack.clear_configuration + Rack::Attack.instance_variable_set(:@cache, nil) + + Rack::Attack.throttled_response = @_original_throttled_response + Rack::Attack.blocklisted_response = @_original_blocklisted_response + end + + def app + Rack::Builder.new do + # Use Rack::Lint to test that rack-attack is complying with the rack spec + use Rack::Lint + use Rack::Attack + use Rack::Lint + + run lambda { |_env| [200, {}, ['Hello World']] } + end.to_app + end + + def self.it_allows_ok_requests it "must allow ok requests" do get '/', {}, 'REMOTE_ADDR' => '127.0.0.1' last_response.status.must_equal 200 diff --git a/spec/support/cache_store_helper.rb b/spec/support/cache_store_helper.rb new file mode 100644 index 0000000..7655949 --- /dev/null +++ b/spec/support/cache_store_helper.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Minitest::Spec + def self.it_works_for_cache_backed_features + it "works for throttle" do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 429, last_response.status + end + + it "works for fail2ban" do + Rack::Attack.blocklist("fail2ban pentesters") do |request| + Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("private-place") + end + end + + get "/" + assert_equal 200, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/private-place" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end + + it "works for allow2ban" do + Rack::Attack.blocklist("allow2ban pentesters") do |request| + Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do + request.path.include?("scarce-resource") + end + end + + get "/" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 200, last_response.status + + get "/scarce-resource" + assert_equal 403, last_response.status + + get "/" + assert_equal 403, last_response.status + end + end +end