diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..718572bf --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..6e4eee24 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,90 @@ +name: build + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-24.04 + services: + redis: + image: redis + ports: + - 6379:6379 + memcached: + image: memcached + ports: + - 11211:11211 + strategy: + fail-fast: false + matrix: + ruby: + - '3.4' + - '3.3' + - '3.2' + - '3.1' + - '3.0' + - '2.7' + gemfile: + - rack_3 + - rack_2 + - rails_8_1 + - rails_8_0 + - rails_7_2 + - rails_7_1 + - rails_7_0 + - dalli3 + - redis_5 + - redis_4 + - active_support_8_1_redis_cache_store + - active_support_8_0_redis_cache_store + - active_support_7_2_redis_cache_store + - active_support_7_1_redis_cache_store + - active_support_7_0_redis_cache_store + - redis_store + exclude: + - gemfile: rails_7_0 + ruby: '3.4' + - gemfile: active_support_7_0_redis_cache_store + ruby: '3.4' + - gemfile: rails_7_2 + ruby: '3.0' + - gemfile: rails_7_2 + ruby: '2.7' + - gemfile: active_support_7_2_redis_cache_store + ruby: '3.0' + - gemfile: active_support_7_2_redis_cache_store + ruby: '2.7' + - gemfile: rails_8_0 + ruby: '3.1' + - gemfile: rails_8_0 + ruby: '3.0' + - gemfile: rails_8_0 + ruby: '2.7' + - gemfile: active_support_8_0_redis_cache_store + ruby: '3.1' + - gemfile: active_support_8_0_redis_cache_store + ruby: '3.0' + - gemfile: active_support_8_0_redis_cache_store + ruby: '2.7' + - gemfile: rails_8_1 + ruby: '3.1' + - gemfile: rails_8_1 + ruby: '3.0' + - gemfile: rails_8_1 + ruby: '2.7' + - gemfile: active_support_8_1_redis_cache_store + ruby: '3.1' + - gemfile: active_support_8_1_redis_cache_store + ruby: '3.0' + - gemfile: active_support_8_1_redis_cache_store + ruby: '2.7' + env: + BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake + diff --git a/.gitignore b/.gitignore index 8624f933..1bb1e055 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 00000000..865fea87 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,119 @@ +require: + - rubocop-minitest + - rubocop-performance + - rubocop-rake + +inherit_mode: + merge: + - Exclude + +AllCops: + TargetRubyVersion: 2.4 + DisabledByDefault: true + NewCops: disable + Exclude: + - "examples/instrumentation.rb" + - "gemfiles/**/*" + +Bundler: + Enabled: true + +Gemspec: + Enabled: true + +Layout: + Enabled: true + +Layout/EmptyLinesAroundAttributeAccessor: # (0.83) + Enabled: true + +Layout/SpaceAroundMethodCallOperator: # (0.82) + Enabled: true + +Layout/LineLength: + Max: 120 + +Lint: + Enabled: true + +Lint/DeprecatedOpenSSLConstant: # (0.84) + Enabled: true + +Lint/RaiseException: # (0.81) + Enabled: true + +Lint/StructNewOverride: # (0.81) + Enabled: true + +Naming: + Enabled: true + Exclude: + - "lib/rack/attack/path_normalizer.rb" + +Performance: + Enabled: true + +Security: + Enabled: true + +Style/BlockDelimiters: + Enabled: true + +Style/ClassAndModuleChildren: + Enabled: true + Exclude: + - "spec/**/*" + +Style/ConditionalAssignment: + Enabled: true + +Style/Encoding: + Enabled: true + +Style/ExpandPathArguments: + Enabled: true + +Style/EmptyMethod: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/HashSyntax: + Enabled: true + +Style/MultilineTernaryOperator: + Enabled: true + +Style/NestedTernaryOperator: + Enabled: true + +Style/OptionalArguments: + Enabled: true + +Style/ParallelAssignment: + Enabled: true + +Style/RaiseArgs: + Enabled: true + +Style/RedundantBegin: + Enabled: true + +Style/RedundantFreeze: + Enabled: true + +Style/RedundantPercentQ: + Enabled: true + +Style/RedundantSelf: + Enabled: true + +Style/Semicolon: + Enabled: true + +Style/SingleLineMethods: + Enabled: true + +Style/SpecialGlobalVars: + Enabled: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 88b518f3..00000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: ruby -rvm: - - 2.1.9 - - 2.2.5 - - 2.3.1 - - jruby-9.1.7.0 - -gemfile: - - gemfiles/activesupport4.1.gemfile - - gemfiles/activesupport4.2.gemfile - - gemfiles/dalli2.gemfile - -services: - - redis - - memcached diff --git a/Appraisals b/Appraisals index bd04b286..9ca0281a 100644 --- a/Appraisals +++ b/Appraisals @@ -1,27 +1,70 @@ -appraise 'activesupport3.2' do - gem 'activesupport', '~> 3.2.0' - gem 'actionpack', '~> 3.2.0' +# frozen_string_literal: true + +appraise "rack_3" do + gem "rack", "~> 3.0" +end + +appraise "rack_2" do + gem "rack", "~> 2.0" +end + +appraise "rails_8-1" do + gem "railties", "~> 8.1.0" +end + +appraise "rails_8-0" do + gem "railties", "~> 8.0.0" +end + +appraise "rails_7-2" do + gem "railties", "~> 7.2.0" +end + +appraise "rails_7-1" do + gem "railties", "~> 7.1.0" +end + +appraise "rails_7-0" do + gem "railties", "~> 7.0.0" +end + +appraise "dalli3" do + gem "dalli", "~> 3.0" +end + +appraise "redis_5" do + gem "redis", "~> 5.0" +end + +appraise "redis_4" do + gem "redis", "~> 4.0" +end + +appraise "active_support_8-1_redis_cache_store" do + gem "activesupport", "~> 8.1.0" + gem "redis", "~> 5.0" end -appraise 'activesupport4.0' do - gem 'activesupport', '~> 4.0.0' - gem 'actionpack', '~> 4.0.0' +appraise "active_support_8-0_redis_cache_store" do + gem "activesupport", "~> 8.0.0" + gem "redis", "~> 5.0" end -appraise 'activesupport4.1' do - gem 'activesupport', '~> 4.1.0' - gem 'actionpack', '~> 4.1.0' +appraise "active_support_7-2_redis_cache_store" do + gem "activesupport", "~> 7.2.0" + gem "redis", "~> 5.0" end -appraise 'activesupport4.2' do - gem 'activesupport', '~> 4.2.0' - gem 'actionpack', '~> 4.2.0' +appraise "active_support_7-1_redis_cache_store" do + gem "activesupport", "~> 7.1.0" + gem "redis", "~> 5.0" end -appraise 'dalli1.1' do - gem 'dalli', '1.1.5' +appraise "active_support_7-0_redis_cache_store" do + gem "activesupport", "~> 7.0.0" + gem "redis", "~> 5.0" end -appraise 'dalli2' do - gem 'dalli', '~> 2.0' +appraise "redis_store" do + gem "redis-store", "~> 1.5" end diff --git a/CHANGELOG.md b/CHANGELOG.md index cec96338..9a832844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,84 +1,350 @@ -# 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. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## v5.0.0.beta1 4 July 2016 +## [6.8.0] - 2025-10-14 - - Deprecate `whitelist`/`blacklist` in favor of `safelist`/`blocklist`. (#181, +### Changed + +- Avoid RedisCacheStore#increment on Rails 6+ (which might improve performance) by @jdelStrother (#597) + +### Fixed + +- Fix #588 don't fail if request.ip is missing, by @santib (#630) +- Fix reset! when using namespaced cache store by @santib (#673) + +### Deprecated + +- Soft deprecate integration with rack 1.x, may or may not work with it. +- Soft deprecate integration with rails 5.2 or less, may or may not work with it. + +### Removed + +- Remove support for unmaintained ActiveSupport::Cache::RedisStore, by @mitchellhenke (#625) + +## [6.7.0] - 2023-07-26 + +- Replace git.io URL by @kyoshidajp in #579 +- test: update rack-test to v2 from v1 by @grzuy in #587 +- Update example description to not suggest using a deprecated method by @MaksimAbramchuk in #589 +- Add note about cache stores and in-memory caches. by @nateberkopec in #604 +- ci: tests against redis gem v5 by @grzuy in #612 +- Support rack 3 by @ioquatix in #586 +- Gem release management. by @ioquatix in #614 + +## [6.6.1] - 2022-04-14 + +### Fixed + +- Fixes deprecation warning in redis 4.6+ ([@ixti]) + +## [6.6.0] - 2022-01-29 + +### Added + +- Ability to have access to the `request` object instead of only `env` (still can access env with `request.env`) when +customizing throttle and blocklist responses with new methods `Rack::Attack.blocklisted_responder=` and +`Rack::Attack.throttled_responder=` which yield the request to your lambda. ([@NikolayRys]) + +### Deprecated + +- `Rack::Attack.blocklisted_response=` +- `Rack::Attack.throttled_response=` + +## [6.5.0] - 2021-02-07 + +### Added + +- Added ability to normalize throttle discriminator by setting `Rack::Attack.throttle_discriminator_normalizer` (@fatkodima) + + Example: + + Rack::Attack.throttle_discriminator_normalizer = ->(discriminator) { ... } + + or disable default normalization with: + + Rack::Attack.throttle_discriminator_normalizer = nil + +### Removed + +- Dropped support for ruby v2.4 +- Dropped support for rails v5.1 + +## [6.4.0] - 2021-01-23 + +### Added + +- Added support for ruby v3.0 + +### Removed + +- Dropped support for ruby v2.3 + +## [6.3.1] - 2020-05-21 + +### Fixed + +- Warning when using `ActiveSupport::Cache::RedisCacheStore` as a cache store with rails 5.2.4.3 (#482) (@rofreg) + +## [6.3.0] - 2020-04-26 + +### Added + +- `Rack::Attack.reset!` to reset state (#436) (@fatkodima) +- `Rack::Attack.throttled_response_retry_after_header=` setting that enables a `Retry-After` response header when client is throttled (#440) (@fatkodima) + +### Changed + +- No longer swallow Redis non-connection errors if Redis is configured as cache store (#450) (@fatkodima) + +### Fixed + +- `Rack::Attack.clear_configuration` also clears `blocklisted_response` and `throttled_response` back to defaults + +## [6.2.2] - 2019-12-18 + +### Fixed + +- Fixed occasional `Redis::FutureNotReady` error (#445) (@fatkodima) + +## [6.2.1] - 2019-10-30 + +### Fixed + +- Remove unintended side-effects on Rails app initialization order. It was potentially affecting the order of `config/initializers/*` in respect to gems initializers (#457) + +## [6.2.0] - 2019-10-12 + +### Added + +- Failsafe on Redis error replies in RedisCacheStoreProxy (#421) (@cristiangreco) +- Rack::Attack middleware is now auto added for Rails 5.1+ apps to simplify gem setup (#431) (@fatkodima) +- You can disable Rack::Attack with `Rack::Attack.enabled = false` (#431) (@fatkodima) + +## [6.1.0] - 2019-07-11 + +### Added + +- Provide throttle discriminator in the env `throttle_data` + +## [6.0.0] - 2019-04-17 + +### Added + +- `#blocklist` and `#safelist` name argument (the first one) is now optional. +- Added support to subscribe only to specific event types via `ActiveSupport::Notifications`, e.g. subscribe to the + `throttle.rack_attack` or the `blocklist.rack_attack` event. + +### Changed + +- Changed `ActiveSupport::Notifications` event naming to comply with the recommended format. +- Changed `ActiveSupport::Notifications` event so that the 5th yielded argument to the `#subscribe` method is now a + `Hash` instead of a `Rack::Attack::Request`, to comply with `ActiveSupport`s spec. The original request object is + still accessible, being the value of the hash's `:request` key. + +### Deprecated + +- Subscriptions via `ActiveSupport::Notifications` to the `"rack.attack"` event will continue to work (receive event + notifications), but it is going to be removed in a future version. Replace the event name with `/rack_attack/` to + continue to be subscribed to all events, or `"throttle.rack_attack"` e.g. for specific type of events only. + +### Removed + +- Removed support for ruby 2.2. +- Removed support for obsolete memcache-client as a cache store. +- Removed deprecated methods `#blacklist` and `#whitelist` (use `#blocklist` and `#safelist` instead). + +## [5.4.2] - 2018-10-30 + +### Fixed + +- Fix unexpected error when using `redis` 3 and any store which is not proxied + +### Changed + +- Provide better information in `MisconfiguredStoreError` exception message to aid end-user debugging + +## [5.4.1] - 2018-09-29 + +### Fixed + +- Make [`ActiveSupport::Cache::MemCacheStore`](http://api.rubyonrails.org/classes/ActiveSupport/Cache/MemCacheStore.html) also work as excepted when initialized with pool options (e.g. `pool_size`). Thank you @jdelStrother. + +## [5.4.0] - 2018-07-02 + +### Added + +- Support "plain" `Redis` as a cache store backend ([#280](https://github.com/rack/rack-attack/pull/280)). Thanks @bfad and @ryandv. +- When overwriting `Rack::Attack.throttled_response` you can now access the exact epoch integer that was used for caching +so your custom code is less prone to race conditions ([#282](https://github.com/rack/rack-attack/pull/282)). Thanks @doliveirakn. + +### Dependency changes + +- Explictly declare ancient `rack 0.x` series as incompatible in gemspec + +## [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/rack/rack-attack/pull/340) and [#350](https://github.com/rack/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/rack/rack-attack/pull/320)) +- Shorthand for blocking an IP subnet `Rack::Attack.blocklist_ip("1.2.0.0/16")` ([#320](https://github.com/rack/rack-attack/pull/320)) +- Shorthand for safelisting an IP address `Rack::Attack.safelist_ip("5.6.7.8")` ([#320](https://github.com/rack/rack-attack/pull/320)) +- Shorthand for safelisting an IP subnet `Rack::Attack.safelist_ip("5.6.0.0/16")` ([#320](https://github.com/rack/rack-attack/pull/320)) +- Throw helpful error message when using `allow2ban` but cache store is misconfigured ([#315](https://github.com/rack/rack-attack/issues/315)) +- Throw helpful error message when using `fail2ban` but cache store is misconfigured ([#315](https://github.com/rack/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/rack/rack-attack/issues/253) ([#271](https://github.com/rack/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/rack/rack-attack/issues/274)) + - Removed legacy code that was originally intended for Rails 3 apps ([#264](https://github.com/rack/rack-attack/issues/264)) + +## [5.0.1] - 2016-08-11 + + - Fixes arguments passed to deprecated internal methods. ([#198](https://github.com/rack/rack-attack/issues/198)) + +## [5.0.0] - 2016-08-09 + + - Deprecate `whitelist`/`blacklist` in favor of `safelist`/`blocklist`. ([#181](https://github.com/rack/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/rack/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/rack/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. + +## 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 + + +[6.8.0]: https://github.com/rack/rack-attack/compare/v6.7.0...v6.8.0/ +[6.7.0]: https://github.com/rack/rack-attack/compare/v6.6.1...v6.7.0/ +[6.6.1]: https://github.com/rack/rack-attack/compare/v6.6.0...v6.6.1/ +[6.6.0]: https://github.com/rack/rack-attack/compare/v6.5.0...v6.6.0/ +[6.5.0]: https://github.com/rack/rack-attack/compare/v6.4.0...v6.5.0/ +[6.4.0]: https://github.com/rack/rack-attack/compare/v6.3.1...v6.4.0/ +[6.3.1]: https://github.com/rack/rack-attack/compare/v6.3.0...v6.3.1/ +[6.3.0]: https://github.com/rack/rack-attack/compare/v6.2.2...v6.3.0/ +[6.2.2]: https://github.com/rack/rack-attack/compare/v6.2.1...v6.2.2/ +[6.2.1]: https://github.com/rack/rack-attack/compare/v6.2.0...v6.2.1/ +[6.2.0]: https://github.com/rack/rack-attack/compare/v6.1.0...v6.2.0/ +[6.1.0]: https://github.com/rack/rack-attack/compare/v6.0.0...v6.1.0/ +[6.0.0]: https://github.com/rack/rack-attack/compare/v5.4.2...v6.0.0/ +[5.4.2]: https://github.com/rack/rack-attack/compare/v5.4.1...v5.4.2/ +[5.4.1]: https://github.com/rack/rack-attack/compare/v5.4.0...v5.4.1/ +[5.4.0]: https://github.com/rack/rack-attack/compare/v5.3.2...v5.4.0/ +[5.3.2]: https://github.com/rack/rack-attack/compare/v5.3.1...v5.3.2/ +[5.3.1]: https://github.com/rack/rack-attack/compare/v5.3.0...v5.3.1/ +[5.3.0]: https://github.com/rack/rack-attack/compare/v5.2.0...v5.3.0/ +[5.2.0]: https://github.com/rack/rack-attack/compare/v5.1.0...v5.2.0/ +[5.1.0]: https://github.com/rack/rack-attack/compare/v5.0.1...v5.1.0/ +[5.0.1]: https://github.com/rack/rack-attack/compare/v5.0.0...v5.0.1/ +[5.0.0]: https://github.com/rack/rack-attack/compare/v4.4.1...v5.0.0/ +[4.4.1]: https://github.com/rack/rack-attack/compare/v4.4.0...v4.4.1/ +[4.4.0]: https://github.com/rack/rack-attack/compare/v4.3.1...v4.4.0/ +[4.3.1]: https://github.com/rack/rack-attack/compare/v4.3.0...v4.3.1/ +[4.3.0]: https://github.com/rack/rack-attack/compare/v4.2.0...v4.3.0/ +[4.2.0]: https://github.com/rack/rack-attack/compare/v4.1.1...v4.2.0/ +[4.1.1]: https://github.com/rack/rack-attack/compare/v4.1.0...v4.1.1/ +[4.1.0]: https://github.com/rack/rack-attack/compare/v4.0.1...v4.1.0/ +[4.0.1]: https://github.com/rack/rack-attack/compare/v4.0.0...v4.0.1/ +[4.0.0]: https://github.com/rack/rack-attack/compare/v3.0.0...v4.0.0/ +[3.0.0]: https://github.com/rack/rack-attack/compare/v2.3.0...v3.0.0/ +[2.3.0]: https://github.com/rack/rack-attack/compare/v2.2.1...v2.3.0/ +[2.2.1]: https://github.com/rack/rack-attack/compare/v2.2.0...v2.2.1/ +[2.2.0]: https://github.com/rack/rack-attack/compare/v2.1.1...v2.2.0/ -## 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 +[@fatkodima]: https://github.com/fatkodima +[@rofreg]: https://github.com/rofreg +[@NikolayRys]: https://github.com/NikolayRys +[@ixti]: https://github.com/ixti +[@santib]: https://github.com/santib +[@jdelStrother]: https://github.com/jdelStrother +[@mitchellhenke]: https://github.com/mitchellhenke diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ad44a62a..a05c39e0 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 00000000..042a12b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# 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 trying to answer their [questions](https://github.com/rack/rack-attack/discussions/categories/questions-q-a) +* Helping users troubleshoot their [error reports](https://github.com/rack/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 [ideas](https://github.com/rack/rack-attack/discussions/categories/ideas-proposals) or [general discussions](https://github.com/rack/rack-attack/discussions/categories/general) +* Open a [new issue](https://github.com/rack/rack-attack/issues/new) if you are experiencing an error and know the 'Steps to reproduce' +* Start a [new discussion](https://github.com/rack/rack-attack/discussions/new) if you have an idea you think it would be useful for many users +* Start a [new discussion](https://github.com/rack/rack-attack/discussions/new) if you have a question +* 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/rack/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 5d00c12d..69753c94 100644 --- a/Gemfile +++ b/Gemfile @@ -1,9 +1,10 @@ +# 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' +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" end diff --git a/Guardfile b/Guardfile deleted file mode 100644 index ebf900b6..00000000 --- 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 526feca9..c7c4c46d 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,142 @@ -# 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. - -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)). +:warning: You are viewing the development's branch version of README which might contain documentation for unreleased features. +For the README consistent with the latest released version see https://github.com/rack/rack-attack/blob/6-stable/README.md. -See the [Backing & Hacking blog post](http://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack. +# Rack::Attack -[![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](http://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 +*Rack middleware for blocking & throttling abusive requests* -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). +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. + +See the [Backing & Hacking blog post](https://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)](https://badge.fury.io/rb/rack-attack) +[![build](https://github.com/rack/rack-attack/actions/workflows/build.yml/badge.svg)](https://github.com/rack/rack-attack/actions/workflows/build.yml) +[![Join the chat at https://gitter.im/rack-attack/rack-attack](https://badges.gitter.im/rack-attack/rack-attack.svg)](https://gitter.im/rack-attack/rack-attack) + +## Table of contents + +- [Getting started](#getting-started) + - [Installing](#installing) + - [Plugging into the application](#plugging-into-the-application) +- [Usage](#usage) + - [Safelisting](#safelisting) + - [`safelist_ip(ip_address_string)`](#safelist_ipip_address_string) + - [`safelist_ip(ip_subnet_string)`](#safelist_ipip_subnet_string) + - [`safelist(name, &block)`](#safelistname-block) + - [Blocking](#blocking) + - [`blocklist_ip(ip_address_string)`](#blocklist_ipip_address_string) + - [`blocklist_ip(ip_subnet_string)`](#blocklist_ipip_subnet_string) + - [`blocklist(name, &block)`](#blocklistname-block) + - [Fail2Ban](#fail2ban) + - [Allow2Ban](#allow2ban) + - [Throttling](#throttling) + - [`throttle(name, options, &block)`](#throttlename-options-block) + - [Tracks](#tracks) + - [Cache store configuration](#cache-store-configuration) +- [Customizing responses](#customizing-responses) + - [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients) +- [Logging & Instrumentation](#logging--instrumentation) +- [Testing](#testing) +- [How it works](#how-it-works) + - [About Tracks](#about-tracks) +- [Performance](#performance) +- [Motivation](#motivation) +- [Contributing](#contributing) +- [Code of Conduct](#code-of-conduct) +- [Development setup](#development-setup) +- [License](#license) ## Getting started -Install the [rack-attack](http://rubygems.org/gems/rack-attack) gem; or add it to your Gemfile with bundler: +### Installing + +Add this line to your application's Gemfile: ```ruby # In your Gemfile -gem 'rack-attack' + +gem "rack-attack", "~> 6.8" ``` -Tell your app to use the Rack::Attack middleware. -For Rails 3+ apps: + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install rack-attack + +### Plugging into the application + +Then tell your ruby web application to use rack-attack as a middleware. + +a) For __rails__ applications it is used by default. + +You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing: ```ruby -# In config/application.rb -config.middleware.use Rack::Attack +Rack::Attack.enabled = false ``` -Or for Rackup files: +b) For __rack__ applications: ```ruby # In config.ru -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 +require "rack/attack" +use Rack::Attack ``` -*Tip:* The example in the wiki is a great way to get started: -[Example Configuration](https://github.com/kickstarter/rack-attack/wiki/Example-Configuration) +__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. -Optionally configure the cache store for throttling or fail2ban filtering: +## Usage -```ruby -Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache -``` +*Tip:* If you just want to get going asap, then you can take our [example configuration](docs/example_configuration.md) +and tailor it to your needs, or check out the [advanced configuration](docs/advanced_configuration.md) examples. -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). +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. -## How it works +### Safelisting -The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. +Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles. - * If the request matches any **safelist**, it is allowed. - * Otherwise, if the request matches any **blocklist**, it is blocked. - * Otherwise, if the request matches any **throttle**, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked. - * Otherwise, all **tracks** are checked, and the request is allowed. +#### `safelist_ip(ip_address_string)` -The algorithm is actually more concise in code: See [Rack::Attack.call](https://github.com/kickstarter/rack-attack/blob/master/lib/rack/attack.rb): +E.g. ```ruby -def call(env) - req = Rack::Attack::Request.new(env) +# config/initializers/rack_attack.rb (for rails app) - if safelisted?(req) - @app.call(env) - elsif blocklisted?(req) - self.class.blocklisted_response.call(env) - elsif throttled?(req) - self.class.throttled_response.call(env) - else - tracked?(req) - @app.call(env) - end -end +Rack::Attack.safelist_ip("5.6.7.8") ``` -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). +#### `safelist_ip(ip_subnet_string)` -## About Tracks +E.g. -`Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. +```ruby +# config/initializers/rack_attack.rb (for rails app) -## Usage +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 allowed, and falsy otherwise. -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). +The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request). -### Safelists +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["HTTP_APIKEY"] == "secret-string" +end + # Always allow requests from localhost # (blocklist & throttles are skipped) Rack::Attack.safelist('allow from localhost') do |req| @@ -112,16 +145,44 @@ Rack::Attack.safelist('allow from localhost') do |req| end ``` -### Blocklists +### Blocking + +#### `blocklist_ip(ip_address_string)` + +E.g. ```ruby -# Block requests from 1.2.3.4 -Rack::Attack.blocklist('block 1.2.3.4') do |req| +# 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 - '1.2.3.4' == req.ip + request.path.start_with?("/admin") 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 @@ -130,17 +191,19 @@ 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 +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 + 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') || @@ -154,8 +217,12 @@ 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. @@ -163,40 +230,49 @@ 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 + 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). -### Throttles +#### `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 -# 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 +# 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('logins/email', :limit => 6, :period => 60.seconds) do |req| - req.params['email'] if req.path == '/login' && req.post? +# Return the *normalized* 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? + # Normalize the email, using the same logic as your authentication process, to + # protect against rate limit bypasses. + req.params['email'].to_s.downcase.gsub(/\s+/, "") + 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.second : 1.minute} -Rack::Attack.throttle('req/ip', :limit => limit_proc, :period => period_proc) do |req| - req.ip +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 ``` @@ -209,35 +285,52 @@ Rack::Attack.track("special_agent") do |req| end # Supports optional limit and period, triggers the notification only when the limit is reached. -Rack::Attack.track("special_agent", :limit => 6, :period => 60.seconds) do |req| +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 +ActiveSupport::Notifications.subscribe("track.rack_attack") do |name, start, finish, instrumenter_id, payload| + req = payload[:request] + if req.env['rack.attack.matched'] == "special_agent" Rails.logger.info "special_agent: #{req.path}" STATSD.increment("special_agent") end end ``` -## Responses +### Cache store configuration + +Throttle, track, 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 +# This is the default +Rack::Attack.cache.store = Rails.cache +# It is recommended to use a separate database for throttling/allow2ban/fail2ban. +Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...") +``` + +Most applications should use a new, separate database used only for `rack-attack`. During an actual attack or periods of heavy load, this database will come under heavy load. Keeping it on a separate database instance will give you additional resilience and make sure that other functions (like caching for your application) don't go down. + +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). This means that other cache stores which inherit from ActiveSupport::Cache::Store are also compatible. In-memory stores which are not backed by an external database, such as `ActiveSupport::Cache::MemoryStore.new`, will be mostly ineffective because each Ruby process in your deployment will have it's own state, effectively multiplying the number of requests each client can make by the number of Ruby processes you have deployed. -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). +## 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.rdoc). ```ruby -Rack::Attack.blocklisted_response = lambda do |env| +Rack::Attack.blocklisted_responder = lambda do |request| # 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| +Rack::Attack.throttled_responder = lambda do |request| # 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'] + # request.env['rack.attack.matched'], + # request.env['rack.attack.match_type'], + # request.env['rack.attack.match_data'], + # request.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 @@ -245,22 +338,27 @@ Rack::Attack.throttled_response = lambda do |env| end ``` -### X-RateLimit headers for well-behaved clients +### 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: +If you want to return to user how many seconds to wait until they can start sending requests again, this can be done through enabling `Retry-After` header: +```ruby +Rack::Attack.throttled_response_retry_after_header = true +``` + +Here's an example response that includes conventional `RateLimit-*` headers: ```ruby -Rack::Attack.throttled_response = lambda do |env| - now = Time.now - match_data = env['rack.attack.match_data'] +Rack::Attack.throttled_responder = lambda do |request| + match_data = request.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.to_i % match_data[:period])).to_s + 'ratelimit-limit' => match_data[:limit].to_s, + 'ratelimit-remaining' => '0', + 'ratelimit-reset' => (now + (match_data[:period] - now % match_data[:period])).to_s } [ 429, headers, ["Throttled\n"]] @@ -271,18 +369,33 @@ 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 } +request.env['rack.attack.throttle_data'][name] # => { discriminator: d, 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: +You can subscribe to `rack_attack` events and log it, graph it, etc. + +To get notified about specific type of events, subscribe to the event name followed by the `rack_attack` namespace. +E.g. for throttles use: ```ruby -ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req| - puts req.inspect +ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, instrumenter_id, payload| + # request object available in payload[:request] + + # Your code here +end +``` + +If you want to subscribe to every `rack_attack` event, use: + +```ruby +ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, instrumenter_id, payload| + # request object available in payload[:request] + + # Your code here end ``` @@ -292,6 +405,50 @@ A note on developing and testing apps using Rack::Attack - if you are using thro need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html) for more on how to do this. +### Disabling + +`Rack::Attack.enabled = false` can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only. + +### Test case isolation + +`Rack::Attack.reset!` can be used in your test suite to clear any Rack::Attack state between different test cases. If you're testing blocklist and safelist configurations, consider using `Rack::Attack.clear_configuration` to unset the values for those lists between test cases. + +## How it works + +The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. + + * If the request matches any **safelist**, it is allowed. + * Otherwise, if the request matches any **blocklist**, it is blocked. + * Otherwise, if the request matches any **throttle**, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked. + * Otherwise, all **tracks** are checked, and the request is allowed. + +The algorithm is actually more concise in code: See [Rack::Attack.call](lib/rack/attack.rb): + +```ruby +def call(env) + req = Rack::Attack::Request.new(env) + + if safelisted?(req) + @app.call(env) + elsif blocklisted?(req) + self.class.blocklisted_responder.call(req) + elsif throttled?(req) + self.class.throttled_responder.call(req) + else + tracked?(req) + @app.call(env) + end +end +``` + +Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you +can cleanly monkey patch helper methods onto the +[request object](lib/rack/attack/request.rb). + +### About Tracks + +`Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. + ## Performance The overhead of running Rack::Attack is typically negligible (a few milliseconds per request), @@ -302,7 +459,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,36 +473,18 @@ 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. - -Install dependencies by running -```sh -bundle install -``` - -Then run the test suite by running -```sh -bundle exec rake -``` +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). -## Mailing list +## Development setup -New releases of Rack::Attack are announced on -. To subscribe, just send an email to -. See the -[archives](http://librelist.com/browser/rack.attack.announce/). +Check out the [Development guide](docs/development.md). ## License 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 5f7e9e0d..caa479ce 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| @@ -10,11 +15,15 @@ namespace :test do Rake::TestTask.new(:integration) do |t| t.pattern = "spec/integration/*_spec.rb" - t.warning = false + 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/advanced_configuration.md b/docs/advanced_configuration.md new file mode 100644 index 00000000..6d8737ea --- /dev/null +++ b/docs/advanced_configuration.md @@ -0,0 +1,109 @@ +## Advanced Configuration + +If you're feeling ambitious or you have a very particular use-case for Rack::Attack, these advanced configurations may help. + +:beetle::warning: Much of this code is untested. Copy-paste at your own risk! + +### Exponential Backoff + +By layering throttles with linearly increasing limits and exponentially increasing periods, you can mimic an exponential backoff throttle. See [#106](https://github.com/rack/rack-attack/issues/106) for more discussion. + +```ruby +# Allows 20 requests in 8 seconds +# 40 requests in 64 seconds +# ... +# 100 requests in 0.38 days (~250 requests/day) +(1..5).each do |level| + throttle("logins/ip/#{level}", :limit => (20 * level), :period => (8 ** level).seconds) do |req| + if req.path == '/login' && req.post? + req.ip + end + end +end +``` + +### Rack::Attack::Request Helpers + +You can define helpers on requests like `localhost?` or `subdomain` by monkey-patching `Rack::Attack::Request`. See [#73](https://github.com/rack/rack-attack/issues/73) for more discussion. + +```ruby +class Rack::Attack::Request < ::Rack::Request + def localhost? + ip == "127.0.0.1" + end +end + +Rack::Attack.safelist("localhost") { |req| req.localhost? } +``` + +### Blocklisting From ENV Variables + +You can have `Rack::Attack` configure its blocklists from ENV variables to simplify maintenance. See [#110](https://github.com/rack/rack-attack/issues/110) for more discussion. + +```ruby +class Rack::Attack + # Split on a comma with 0 or more spaces after it. + # E.g. ENV['HEROKU_VARIABLE'] = "foo.com, bar.com" + # spammers = ["foo.com", "bar.com"] + spammers = ENV['HEROKU_VARIABLE'].split(/,\s*/) + + # Turn spammers array into a regexp + spammer_regexp = Regexp.union(spammers) # /foo\.com|bar\.com/ + blocklist("block referer spam") do |request| + request.referer =~ spammer_regexp + end +end +``` + +### Reset Specific Throttles + +By doing a bunch of monkey-patching, you can add a helper for resetting specific throttles. The implementation is kind of long, so see [#113](https://github.com/rack/rack-attack/issues/113) for more discussion. + +```ruby +Rack::Attack.reset_throttle "logins/email", "user@example.com" +``` + +### Blocklisting From Rails.cache + +You can configure blocklists to check values stored in `Rails.cache` to allow setting blocklists from inside your application. See [#111](https://github.com/rack/rack-attack/issues/111) for more discussion. + +```ruby +# Block attacks from IPs in cache +# To add an IP: Rails.cache.write("block 1.2.3.4", true, expires_in: 2.days) +# To remove an IP: Rails.cache.delete("block 1.2.3.4") +Rack::Attack.blocklist("block IP") do |req| + Rails.cache.read("block #{req.ip}") +end +``` + +### Throttle Basic Auth Crackers + +An example implementation for blocking hackers who spam basic auth attempts. See [#47](https://github.com/rack/rack-attack/issues/47) for more discussion. + +```ruby +# After 5 requests with incorrect auth in 1 minute, +# block all requests from that IP for 1 hour. +Rack::Attack.blocklist('basic auth crackers') do |req| + Rack::Attack::Allow2Ban.filter(req.ip, :maxretry => 5, :findtime => 1.minute, :bantime => 1.hour) do + # Return true if the authorization header is incorrect + auth = Rack::Auth::Basic::Request.new(req.env) + auth.credentials != [my_username, my_password] + end +end +``` + +### Match Actions in Rails + +Instead of matching the URL with complex regex, it can be much easier to match specific controller actions: + +```ruby +Rack::Attack.safelist('unlimited requests') do |request| + safelist = [ + 'controller#action', + 'another_controller#another_action' + ] + route = (Rails.application.routes.recognize_path request.url rescue {}) || {} + action = "#{route[:controller]}##{route[:action]}" + safelist.any? { |safe| action == safe } +end +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..b32cecf8 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,17 @@ +# 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 + +Install test dependencies by running: + + $ bundle exec appraisal install + +Then run the test suite by running + + $ bundle exec appraisal rake test diff --git a/docs/example_configuration.md b/docs/example_configuration.md new file mode 100644 index 00000000..cfe77581 --- /dev/null +++ b/docs/example_configuration.md @@ -0,0 +1,84 @@ +## Example Configuration + +If you just go ahead and copy this to `/config/initializers/rack_attack.rb`, then you'll be safe from 95% of bad requests. This won't stop sophisticated hackers, but at least you can sleep more soundly knowing that your application isn't going to be accidentally taken down by a misconfigured web scraper in the middle of the night. If this isn't enough for you, check out [Advanced Configuration](advanced_configuration.md) too. + +```ruby +class Rack::Attack + + ### Configure Cache ### + + # If you don't want to use Rails.cache (Rack::Attack's default), then + # configure it here. + # + # Note: The store is only used for throttling (not blocklisting and + # safelisting). It must implement .increment and .write like + # ActiveSupport::Cache::Store + + # Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + + ### Throttle Spammy Clients ### + + # If any single client IP is making tons of requests, then they're + # probably malicious or a poorly-configured scraper. Either way, they + # don't deserve to hog all of the app server's CPU. Cut them off! + # + # Note: If you're serving assets through rack, those requests may be + # counted by rack-attack and this throttle may be activated too + # quickly. If so, enable the condition to exclude them from tracking. + + # Throttle all requests by IP (60rpm) + # + # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" + throttle('req/ip', limit: 300, period: 5.minutes) do |req| + req.ip # unless req.path.start_with?('/assets') + end + + ### Prevent Brute-Force Login Attacks ### + + # The most common brute-force login attack is a brute-force password + # attack where an attacker simply tries a large number of emails and + # passwords to see if any credentials match. + # + # Another common method of attack is to use a swarm of computers with + # different IPs to try brute-forcing a password for a specific account. + + # Throttle POST requests to /login by IP address + # + # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" + throttle('logins/ip', limit: 5, period: 20.seconds) do |req| + if req.path == '/login' && req.post? + req.ip + end + end + + # Throttle POST requests to /login by email param + # + # Key: "rack::attack:#{Time.now.to_i/:period}:logins/email:#{normalized_email}" + # + # Note: This creates a problem where a malicious user could intentionally + # throttle logins for another user and force their login requests to be + # denied, but that's not very common and shouldn't happen to you. (Knock + # on wood!) + throttle('logins/email', limit: 5, period: 20.seconds) do |req| + if req.path == '/login' && req.post? + # Normalize the email, using the same logic as your authentication process, to + # protect against rate limit bypasses. Return the normalized email if present, nil otherwise. + req.params['email'].to_s.downcase.gsub(/\s+/, "").presence + end + end + + ### Custom Throttle Response ### + + # By default, Rack::Attack returns an HTTP 429 for throttled responses, + # which is just fine. + # + # If you want to return 503 so that the attacker might be fooled into + # believing that they've successfully broken your app (or you just want to + # customize the response), then uncomment these lines. + # self.throttled_responder = lambda do |env| + # [ 503, # status + # {}, # headers + # ['']] # body + # end +end +``` diff --git a/examples/instrumentation.rb b/examples/instrumentation.rb index 57506174..6a22164a 100644 --- a/examples/instrumentation.rb +++ b/examples/instrumentation.rb @@ -1,3 +1,3 @@ -ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req| - puts req.inspect +ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload| + puts payload[:request].inspect end diff --git a/examples/rack_attack.rb b/examples/rack_attack.rb index 5cb8cc88..7423f39a 100644 --- a/examples/rack_attack.rb +++ b/examples/rack_attack.rb @@ -1,18 +1,22 @@ +# frozen_string_literal: true + # NB: `req` is a Rack::Request object (basically an env hash with friendly accessor methods) # Throttle 10 requests/ip/second # NB: return value of block is key name for counter # falsy values bypass throttling -Rack::Attack.throttle("req/ip", :limit => 10, :period => 1) { |req| req.ip } +Rack::Attack.throttle("req/ip", limit: 10, period: 1) { |req| req.ip } # Throttle attempts to a particular path. 2 POSTs to /login per second per IP -Rack::Attack.throttle "logins/ip", :limit => 2, :period => 1 do |req| +Rack::Attack.throttle "logins/ip", limit: 2, period: 1 do |req| req.post? && req.path == "/login" && req.ip end # Throttle login attempts per email, 10/minute/email -Rack::Attack.throttle "logins/email", :limit => 2, :period => 60 do |req| - req.post? && req.path == "/login" && req.params['email'] +# Normalize the email, using the same logic as your authentication process, to +# protect against rate limit bypasses. +Rack::Attack.throttle "logins/email", limit: 2, period: 60 do |req| + req.post? && req.path == "/login" && req.params['email'].to_s.downcase.gsub(/\s+/, "") end # blocklist bad IPs from accessing admin pages diff --git a/gemfiles/active_support_7_0_redis_cache_store.gemfile b/gemfiles/active_support_7_0_redis_cache_store.gemfile new file mode 100644 index 00000000..a94cfe88 --- /dev/null +++ b/gemfiles/active_support_7_0_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "activesupport", "~> 7.0.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/active_support_7_1_redis_cache_store.gemfile b/gemfiles/active_support_7_1_redis_cache_store.gemfile new file mode 100644 index 00000000..a0602ba5 --- /dev/null +++ b/gemfiles/active_support_7_1_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "activesupport", "~> 7.1.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/active_support_7_2_redis_cache_store.gemfile b/gemfiles/active_support_7_2_redis_cache_store.gemfile new file mode 100644 index 00000000..3c433092 --- /dev/null +++ b/gemfiles/active_support_7_2_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "activesupport", "~> 7.2.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/active_support_8_0_redis_cache_store.gemfile b/gemfiles/active_support_8_0_redis_cache_store.gemfile new file mode 100644 index 00000000..b813cb38 --- /dev/null +++ b/gemfiles/active_support_8_0_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "activesupport", "~> 8.0.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/active_support_8_1_redis_cache_store.gemfile b/gemfiles/active_support_8_1_redis_cache_store.gemfile new file mode 100644 index 00000000..57e0ff21 --- /dev/null +++ b/gemfiles/active_support_8_1_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "activesupport", "~> 8.1.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/activesupport4.1.gemfile b/gemfiles/activesupport4.1.gemfile deleted file mode 100644 index 8eae1c9e..00000000 --- a/gemfiles/activesupport4.1.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org/" - -gem "activesupport", "~> 4.1.0" -gem "actionpack", "~> 4.1.0" -gem "listen", "<= 3.0.6", platforms: [:ruby_21, :jruby] - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - -gemspec :path => "../" diff --git a/gemfiles/activesupport4.2.gemfile b/gemfiles/activesupport4.2.gemfile deleted file mode 100644 index e642baf4..00000000 --- a/gemfiles/activesupport4.2.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org/" - -gem "activesupport", "~> 4.2.0" -gem "actionpack", "~> 4.2.0" -gem "listen", "<= 3.0.6", platforms: [:ruby_21, :jruby] - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - -gemspec :path => "../" diff --git a/gemfiles/activesupport5.0.gemfile b/gemfiles/activesupport5.0.gemfile deleted file mode 100644 index 6a03d133..00000000 --- a/gemfiles/activesupport5.0.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org/" - -gem "activesupport", "~> 5.0.0" -gem "actionpack", "~> 5.0.0" -gem "listen", "<= 3.0.6", platforms: [:ruby_21, :jruby] - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - -gemspec :path => "../" diff --git a/gemfiles/dalli2.gemfile b/gemfiles/dalli2.gemfile deleted file mode 100644 index 0367e1d7..00000000 --- a/gemfiles/dalli2.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org/" - -gem "dalli", "~> 2.0" -gem "rack", "<= 1.4.7", platforms: [:ruby_21, :jruby] -gem "activesupport", "<= 3.2.22.2", platforms: [:ruby_21, :jruby] -gem "listen", "<= 3.0.6", platforms: [:ruby_21, :jruby] - -group :development do - gem "pry" - gem "guard" - gem "guard-minitest" -end - -gemspec :path => "../" diff --git a/gemfiles/dalli3.gemfile b/gemfiles/dalli3.gemfile new file mode 100644 index 00000000..3873dedf --- /dev/null +++ b/gemfiles/dalli3.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "dalli", "~> 3.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rack_2.gemfile b/gemfiles/rack_2.gemfile new file mode 100644 index 00000000..246c981a --- /dev/null +++ b/gemfiles/rack_2.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "rack", "~> 2.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rack_3.gemfile b/gemfiles/rack_3.gemfile new file mode 100644 index 00000000..f0735014 --- /dev/null +++ b/gemfiles/rack_3.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "rack", "~> 3.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rails_7_0.gemfile b/gemfiles/rails_7_0.gemfile new file mode 100644 index 00000000..6f490fff --- /dev/null +++ b/gemfiles/rails_7_0.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "railties", "~> 7.0.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile new file mode 100644 index 00000000..fdfb546f --- /dev/null +++ b/gemfiles/rails_7_1.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "railties", "~> 7.1.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile new file mode 100644 index 00000000..3d3032b4 --- /dev/null +++ b/gemfiles/rails_7_2.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "railties", "~> 7.2.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile new file mode 100644 index 00000000..1f1f083a --- /dev/null +++ b/gemfiles/rails_8_0.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "railties", "~> 8.0.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile new file mode 100644 index 00000000..92df38d6 --- /dev/null +++ b/gemfiles/rails_8_1.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "railties", "~> 8.1.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/redis_4.gemfile b/gemfiles/redis_4.gemfile new file mode 100644 index 00000000..e8b82f16 --- /dev/null +++ b/gemfiles/redis_4.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "redis", "~> 4.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/redis_5.gemfile b/gemfiles/redis_5.gemfile new file mode 100644 index 00000000..fc9b4655 --- /dev/null +++ b/gemfiles/redis_5.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/redis_store.gemfile b/gemfiles/redis_store.gemfile new file mode 100644 index 00000000..e32d1e9e --- /dev/null +++ b/gemfiles/redis_store.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org/" + +gem "redis-store", "~> 1.5" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/lib/rack/attack.rb b/lib/rack/attack.rb index 0f375677..c9094b21 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -1,156 +1,133 @@ +# frozen_string_literal: true + require 'rack' require 'forwardable' - -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 :Fail2Ban, 'rack/attack/fail2ban' - autoload :Allow2Ban, 'rack/attack/allow2ban' - autoload :Request, 'rack/attack/request' - - 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) - end - - def throttle(name, options, &block) - self.throttles[name] = Throttle.new(name, options, block) - end - - def track(name, options = {}, &block) - self.tracks[name] = Track.new(name, options, block) - 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 - end - - def blacklists - warn "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead." - blocklists - end - - def safelisted?(req) - safelists.any? do |name, safelist| - safelist[req] +require 'rack/attack/cache' +require 'rack/attack/configuration' +require 'rack/attack/path_normalizer' +require 'rack/attack/request' +require 'rack/attack/store_proxy/dalli_proxy' +require 'rack/attack/store_proxy/mem_cache_store_proxy' +require 'rack/attack/store_proxy/redis_proxy' +require 'rack/attack/store_proxy/redis_store_proxy' +require 'rack/attack/store_proxy/redis_cache_store_proxy' + +require 'rack/attack/railtie' if defined?(::Rails) + +module Rack + class Attack + class Error < StandardError; end + + class MisconfiguredStoreError < Error; end + + class MissingStoreError < Error; end + + class IncompatibleStoreError < Error; end + + 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 :Fail2Ban, 'rack/attack/fail2ban' + autoload :Allow2Ban, 'rack/attack/allow2ban' + + class << self + attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer + attr_reader :configuration + + def instrument(request) + if notifier + event_type = request.env["rack.attack.match_type"] + notifier.instrument("#{event_type}.rack_attack", request: request) + + # Deprecated: Keeping just for backwards compatibility + notifier.instrument("rack.attack", request: request) + end 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 cache + @cache ||= Cache.new 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] + def clear! + warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead" + @configuration.clear_configuration end - end - def tracked?(req) - tracks.each_value do |tracker| - tracker[req] + def reset! + cache.reset! end - end - - def instrument(req) - notifier.instrument('rack.attack', req) if notifier - end - - def cache - @cache ||= Cache.new - end - - def clear! - @safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {} - end - def blacklisted_response=(res) - warn "[DEPRECATION] 'Rack::Attack.blacklisted_response=' is deprecated. Please use 'blocklisted_response=' instead." - self.blocklisted_response=(res) - end - - def blacklisted_response - warn "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead." - self.blocklisted_response - 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| - retry_after = (env['rack.attack.match_data'] || {})[:period] - [429, {'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s}, ["Retry later\n"]] - } - - def initialize(app) - @app = app - end - - def call(env) - env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) - req = Rack::Attack::Request.new(env) - - if safelisted?(req) - @app.call(env) - elsif blocklisted?(req) - self.class.blocklisted_response.call(env) - elsif throttled?(req) - self.class.throttled_response.call(env) - else - tracked?(req) - @app.call(env) + extend Forwardable + def_delegators( + :@configuration, + :safelist, + :blocklist, + :blocklist_ip, + :safelist_ip, + :throttle, + :track, + :throttled_responder, + :throttled_responder=, + :blocklisted_responder, + :blocklisted_responder=, + :blocklisted_response, + :blocklisted_response=, + :throttled_response, + :throttled_response=, + :throttled_response_retry_after_header, + :throttled_response_retry_after_header=, + :clear_configuration, + :safelists, + :blocklists, + :throttles, + :tracks + ) + end + + # Set defaults + @enabled = true + @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) + @throttle_discriminator_normalizer = lambda do |discriminator| + discriminator.to_s.strip.downcase + end + @configuration = Configuration.new + + attr_reader :configuration + + def initialize(app) + @app = app + @configuration = self.class.configuration + end + + def call(env) + return @app.call(env) if !self.class.enabled || env["rack.attack.called"] + + env["rack.attack.called"] = true + env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) + request = Rack::Attack::Request.new(env) + + if configuration.safelisted?(request) + @app.call(env) + elsif configuration.blocklisted?(request) + # Deprecated: Keeping blocklisted_response for backwards compatibility + if configuration.blocklisted_response + configuration.blocklisted_response.call(env) + else + configuration.blocklisted_responder.call(request) + end + elsif configuration.throttled?(request) + # Deprecated: Keeping throttled_response for backwards compatibility + if configuration.throttled_response + configuration.throttled_response.call(env) + else + configuration.throttled_responder.call(request) + end + else + configuration.tracked?(request) + @app.call(env) + end end end - - extend Forwardable - def_delegators self, :safelisted?, - :blocklisted?, - :throttled?, - :tracked? end diff --git a/lib/rack/attack/allow2ban.rb b/lib/rack/attack/allow2ban.rb index 763253b8..faa3518d 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/base_proxy.rb b/lib/rack/attack/base_proxy.rb new file mode 100644 index 00000000..f10af3d4 --- /dev/null +++ b/lib/rack/attack/base_proxy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'delegate' + +module Rack + class Attack + class BaseProxy < SimpleDelegator + class << self + def proxies + @@proxies ||= [] + end + + def inherited(klass) + super + proxies << klass + end + + def lookup(store) + proxies.find { |proxy| proxy.handle?(store) } + end + + def handle?(_store) + raise NotImplementedError + end + end + end + end +end diff --git a/lib/rack/attack/blocklist.rb b/lib/rack/attack/blocklist.rb index b61a29fe..08adcc6d 100644 --- a/lib/rack/attack/blocklist.rb +++ b/lib/rack/attack/blocklist.rb @@ -1,11 +1,12 @@ +# frozen_string_literal: true + module Rack class Attack class Blocklist < Check - def initialize(name, block) + def initialize(name = nil, &block) super @type = :blocklist end - end end end diff --git a/lib/rack/attack/cache.rb b/lib/rack/attack/cache.rb index c2dd06c8..9111ab8a 100644 --- a/lib/rack/attack/cache.rb +++ b/lib/rack/attack/cache.rb @@ -1,17 +1,31 @@ +# frozen_string_literal: true + module Rack class Attack class Cache - attr_accessor :prefix + attr_reader :last_epoch_time + + def self.default_store + if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) + ::Rails.cache + end + end - def initialize - self.store = ::Rails.cache if defined?(::Rails.cache) + def initialize(store: self.class.default_store) + self.store = store @prefix = 'rack::attack' end attr_reader :store + def store=(store) - @store = StoreProxy.build(store) + @store = + if (proxy = BaseProxy.lookup(store)) + proxy.new(store) + else + store + end end def count(unprefixed_key, period) @@ -20,11 +34,14 @@ def count(unprefixed_key, period) end def read(unprefixed_key) + enforce_store_presence! + enforce_store_method_presence!(:read) + store.read("#{prefix}:#{unprefixed_key}") end def write(unprefixed_key, value, expires_in) - store.write("#{prefix}:#{unprefixed_key}", value, :expires_in => expires_in) + store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in) end def reset_count(unprefixed_key, period) @@ -36,25 +53,55 @@ def delete(unprefixed_key) store.delete("#{prefix}:#{unprefixed_key}") end + def reset! + if store.respond_to?(:delete_matched) + store.delete_matched(/#{prefix}*/) + else + raise( + Rack::Attack::IncompatibleStoreError, + "Configured store #{store.class.name} doesn't respond to #delete_matched method" + ) + end + end + 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://github.com/rack/rack-attack/pull/85 + 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) - result = store.increment(key, 1, :expires_in => 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? - store.write(key, 1, :expires_in => expires_in) + 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, + "Configured store #{store.class.name} doesn't respond to ##{method_name} method" + ) + end + end end end end diff --git a/lib/rack/attack/check.rb b/lib/rack/attack/check.rb index 21451ded..c9f3ff7d 100644 --- a/lib/rack/attack/check.rb +++ b/lib/rack/attack/check.rb @@ -1,23 +1,25 @@ +# frozen_string_literal: true + module Rack class Attack class Check attr_reader :name, :block, :type - def initialize(name, options = {}, block) - @name, @block = name, block + + def initialize(name, options = {}, &block) + @name = name + @block = block @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/configuration.rb b/lib/rack/attack/configuration.rb new file mode 100644 index 00000000..81f81915 --- /dev/null +++ b/lib/rack/attack/configuration.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "ipaddr" + +module Rack + class Attack + class Configuration + DEFAULT_BLOCKLISTED_RESPONDER = lambda { |_req| [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] } + + DEFAULT_THROTTLED_RESPONDER = lambda do |req| + if Rack::Attack.configuration.throttled_response_retry_after_header + match_data = req.env['rack.attack.match_data'] + now = match_data[:epoch_time] + retry_after = match_data[:period] - (now % match_data[:period]) + + [429, { 'content-type' => 'text/plain', 'retry-after' => retry_after.to_s }, ["Retry later\n"]] + else + [429, { 'content-type' => 'text/plain' }, ["Retry later\n"]] + end + end + + attr_reader :safelists, :blocklists, :throttles, :tracks, :anonymous_blocklists, :anonymous_safelists + attr_accessor :blocklisted_responder, :throttled_responder, :throttled_response_retry_after_header + + attr_reader :blocklisted_response, :throttled_response # Keeping these for backwards compatibility + + def blocklisted_response=(responder) + warn "[DEPRECATION] Rack::Attack.blocklisted_response is deprecated. "\ + "Please use Rack::Attack.blocklisted_responder instead." + @blocklisted_response = responder + end + + def throttled_response=(responder) + warn "[DEPRECATION] Rack::Attack.throttled_response is deprecated. "\ + "Please use Rack::Attack.throttled_responder instead" + @throttled_response = responder + end + + def initialize + set_defaults + end + + def safelist(name = nil, &block) + safelist = Safelist.new(name, &block) + + if name + @safelists[name] = safelist + else + @anonymous_safelists << safelist + end + end + + def blocklist(name = nil, &block) + blocklist = Blocklist.new(name, &block) + + if name + @blocklists[name] = blocklist + else + @anonymous_blocklists << blocklist + end + end + + def blocklist_ip(ip_address) + @anonymous_blocklists << Blocklist.new do |request| + request.ip && !request.ip.empty? && IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) + end + end + + def safelist_ip(ip_address) + @anonymous_safelists << Safelist.new do |request| + request.ip && !request.ip.empty? && IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) + end + end + + def throttle(name, options, &block) + @throttles[name] = Throttle.new(name, options, &block) + end + + def track(name, options = {}, &block) + @tracks[name] = Track.new(name, options, &block) + end + + def safelisted?(request) + @anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } || + @safelists.any? { |_name, safelist| safelist.matched_by?(request) } + end + + def blocklisted?(request) + @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } || + @blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) } + end + + def throttled?(request) + @throttles.any? do |_name, throttle| + throttle.matched_by?(request) + end + end + + def tracked?(request) + @tracks.each_value do |track| + track.matched_by?(request) + end + end + + def clear_configuration + set_defaults + end + + private + + def set_defaults + @safelists = {} + @blocklists = {} + @throttles = {} + @tracks = {} + @anonymous_blocklists = [] + @anonymous_safelists = [] + @throttled_response_retry_after_header = false + + @blocklisted_responder = DEFAULT_BLOCKLISTED_RESPONDER + @throttled_responder = DEFAULT_THROTTLED_RESPONDER + + # Deprecated: Keeping these for backwards compatibility + @blocklisted_response = nil + @throttled_response = nil + end + end + end +end diff --git a/lib/rack/attack/fail2ban.rb b/lib/rack/attack/fail2ban.rb index 76dc59fa..b43c7cba 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 @@ def banned?(discriminator) end protected + def key_prefix 'fail2ban' end @@ -40,8 +43,8 @@ def fail!(discriminator, bantime, findtime, maxretry) 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 afeb2f93..deafa888 100644 --- a/lib/rack/attack/path_normalizer.rb +++ b/lib/rack/attack/path_normalizer.rb @@ -1,27 +1,28 @@ -class Rack::Attack +# frozen_string_literal: true - # 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.) - # - # Look for an ActionDispatch utility class that Rails folks would expect - # to normalize request paths. If unavailable, use a fallback class that - # doesn't normalize the path (as a non-Rails rack app developer expects). +module Rack + class Attack + # When using Rack::Attack with a Rails app, developers expect the request path + # to be normalized. In particular, trailing slashes are stripped. + # (See + # https://github.com/rails/rails/blob/f8edd20/actionpack/lib/action_dispatch/journey/router/utils.rb#L5-L22 + # for implementation.) + # + # Look for an ActionDispatch utility class that Rails folks would expect + # to normalize request paths. If unavailable, use a fallback class that + # doesn't normalize the path (as a non-Rails rack app developer expects). - module FallbackPathNormalizer - def self.normalize_path(path) - path + module FallbackPathNormalizer + def self.normalize_path(path) + path + end end - end - - PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils) - # For Rails 4+ apps - ::ActionDispatch::Journey::Router::Utils - elsif defined?(::Journey::Router::Utils) - # for Rails 3.2 - ::Journey::Router::Utils - else - FallbackPathNormalizer - end + PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils) + # For Rails apps + ::ActionDispatch::Journey::Router::Utils + else + FallbackPathNormalizer + end + end end diff --git a/lib/rack/attack/railtie.rb b/lib/rack/attack/railtie.rb new file mode 100644 index 00000000..9521493b --- /dev/null +++ b/lib/rack/attack/railtie.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +begin + require 'rails/railtie' +rescue LoadError + return +end + +module Rack + class Attack + class Railtie < ::Rails::Railtie + initializer "rack-attack.middleware" do |app| + app.middleware.use(Rack::Attack) + end + end + end +end diff --git a/lib/rack/attack/request.rb b/lib/rack/attack/request.rb index ee05f89a..43de43d1 100644 --- a/lib/rack/attack/request.rb +++ b/lib/rack/attack/request.rb @@ -1,4 +1,7 @@ -# Rack::Attack::Request is the same as ::Rack::Request by default. +# frozen_string_literal: true + +# Rack::Attack::Request is the same as :ActionDispatch::Request in Rails apps, +# and ::Rack::Request in other apps by default. # # This is a safe place to add custom helper methods to the request object # through monkey patching: @@ -13,7 +16,7 @@ # module Rack class Attack - class Request < ::Rack::Request + class Request < defined?(::ActionDispatch::Request) ? ::ActionDispatch::Request : ::Rack::Request end end end diff --git a/lib/rack/attack/safelist.rb b/lib/rack/attack/safelist.rb index 748d422b..b3abeaf2 100644 --- a/lib/rack/attack/safelist.rb +++ b/lib/rack/attack/safelist.rb @@ -1,11 +1,12 @@ +# frozen_string_literal: true + module Rack class Attack class Safelist < Check - def initialize(name, block) + def initialize(name = nil, &block) super @type = :safelist end - end end end diff --git a/lib/rack/attack/store_proxy.rb b/lib/rack/attack/store_proxy.rb deleted file mode 100644 index 4d698538..00000000 --- a/lib/rack/attack/store_proxy.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Rack - class Attack - module StoreProxy - PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy] - - ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore']).freeze - ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze - - def self.build(store) - client = unwrap_active_support_stores(store) - klass = PROXIES.find { |proxy| proxy.handle?(client) } - 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. - # We also want to use the underlying Dalli client instead of ::ActiveSupport::Cache::MemCacheStore, - # and the MemCache client if using Rails 3.x - - client = store.instance_variable_get(:@data) - if ACTIVE_SUPPORT_WRAPPER_CLASSES.include?(store.class.to_s) && ACTIVE_SUPPORT_CLIENTS.include?(client.class.to_s) - client - else - store - end - end - end - end -end diff --git a/lib/rack/attack/store_proxy/dalli_proxy.rb b/lib/rack/attack/store_proxy/dalli_proxy.rb index 703f2d66..48198bb2 100644 --- a/lib/rack/attack/store_proxy/dalli_proxy.rb +++ b/lib/rack/attack/store_proxy/dalli_proxy.rb @@ -1,9 +1,11 @@ -require 'delegate' +# frozen_string_literal: true + +require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy - class DalliProxy < SimpleDelegator + class DalliProxy < BaseProxy def self.handle?(store) return false unless defined?(::Dalli) @@ -22,31 +24,35 @@ def initialize(client) end def read(key) - with do |client| - client.get(key) + rescuing do + with do |client| + client.get(key) + end end - rescue Dalli::DalliError end - def write(key, value, options={}) - with do |client| - client.set(key, value, options.fetch(:expires_in, 0), raw: true) + def write(key, value, options = {}) + rescuing do + with do |client| + client.set(key, value, options.fetch(:expires_in, 0), raw: true) + end end - rescue Dalli::DalliError end - def increment(key, amount, options={}) - with do |client| - client.incr(key, amount, options.fetch(:expires_in, 0), amount) + def increment(key, amount, options = {}) + rescuing do + with do |client| + client.incr(key, amount, options.fetch(:expires_in, 0), amount) + end end - rescue Dalli::DalliError end def delete(key) - with do |client| - client.delete(key) + rescuing do + with do |client| + client.delete(key) + end end - rescue Dalli::DalliError end private @@ -54,11 +60,18 @@ def delete(key) def stub_with_if_missing unless __getobj__.respond_to?(:with) class << self - def with; yield __getobj__; end + def with + yield __getobj__ + end end end end + def rescuing + yield + rescue Dalli::DalliError + nil + 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 deleted file mode 100644 index 098e0480..00000000 --- a/lib/rack/attack/store_proxy/mem_cache_proxy.rb +++ /dev/null @@ -1,51 +0,0 @@ -module Rack - class Attack - module StoreProxy - class MemCacheProxy < SimpleDelegator - def self.handle?(store) - defined?(::MemCache) && store.is_a?(::MemCache) - end - - def initialize(store) - super(store) - stub_with_if_missing - end - - def read(key) - # Second argument: reading raw value - get(key, true) - rescue MemCache::MemCacheError - end - - 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={}) - incr(key, amount) - rescue MemCache::MemCacheError - end - - def delete(key, options={}) - with do |client| - client.delete(key) - end - rescue MemCache::MemCacheError - end - - private - - def stub_with_if_missing - unless __getobj__.respond_to?(:with) - class << self - def with; yield __getobj__; end - end - end - end - - end - end - end -end diff --git a/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb b/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb new file mode 100644 index 00000000..f7b66c92 --- /dev/null +++ b/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rack/attack/base_proxy' + +module Rack + class Attack + module StoreProxy + class MemCacheStoreProxy < BaseProxy + def self.handle?(store) + defined?(::Dalli) && + defined?(::ActiveSupport::Cache::MemCacheStore) && + store.is_a?(::ActiveSupport::Cache::MemCacheStore) + 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_cache_store_proxy.rb b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb new file mode 100644 index 00000000..74f665b5 --- /dev/null +++ b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rack/attack/base_proxy' + +module Rack + class Attack + module StoreProxy + class RedisCacheStoreProxy < BaseProxy + def self.handle?(store) + store.class.name == "ActiveSupport::Cache::RedisCacheStore" + end + + if defined?(::ActiveSupport) && ::ActiveSupport::VERSION::MAJOR < 6 + def increment(name, amount = 1, **options) + # RedisCacheStore#increment ignores options[:expires_in] in versions prior to 6. + # + # 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 + 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 + + def delete_matched(matcher, options = nil) + super(matcher.source, options) + end + end + end + end +end diff --git a/lib/rack/attack/store_proxy/redis_proxy.rb b/lib/rack/attack/store_proxy/redis_proxy.rb new file mode 100644 index 00000000..599213ae --- /dev/null +++ b/lib/rack/attack/store_proxy/redis_proxy.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rack/attack/base_proxy' + +module Rack + class Attack + module StoreProxy + class RedisProxy < BaseProxy + 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 self.handle?(store) + defined?(::Redis) && store.class == ::Redis + end + + def read(key) + rescuing { get(key) } + end + + def write(key, value, options = {}) + if (expires_in = options[:expires_in]) + rescuing { setex(key, expires_in, value) } + else + rescuing { set(key, value) } + end + end + + def increment(key, amount, options = {}) + rescuing do + pipelined do |redis| + redis.incrby(key, amount) + redis.expire(key, options[:expires_in]) if options[:expires_in] + end.first + end + end + + def delete(key, _options = {}) + rescuing { del(key) } + end + + def delete_matched(matcher, _options = nil) + cursor = "0" + source = matcher.source + + rescuing do + # Fetch keys in batches using SCAN to avoid blocking the Redis server. + loop do + cursor, keys = scan(cursor, match: source, count: 1000) + del(*keys) unless keys.empty? + break if cursor == "0" + end + end + end + + private + + def rescuing + yield + rescue Redis::BaseConnectionError + nil + 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 be3c412c..28557bcb 100644 --- a/lib/rack/attack/store_proxy/redis_store_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_store_proxy.rb @@ -1,44 +1,25 @@ -require 'delegate' +# frozen_string_literal: true + +require 'rack/attack/store_proxy/redis_proxy' module Rack class Attack module StoreProxy - class RedisStoreProxy < SimpleDelegator + class RedisStoreProxy < RedisProxy def self.handle?(store) defined?(::Redis::Store) && store.is_a?(::Redis::Store) end - def initialize(store) - super(store) - end - def read(key) - self.get(key, raw: true) - rescue Redis::BaseError + rescuing { get(key, raw: true) } end - def write(key, value, options={}) + def write(key, value, options = {}) if (expires_in = options[:expires_in]) - self.setex(key, expires_in, value, raw: true) + rescuing { setex(key, expires_in, value, raw: true) } else - self.set(key, value, raw: true) - end - rescue Redis::BaseError - end - - def increment(key, amount, options={}) - count = nil - self.pipelined do - count = self.incrby(key, amount) - self.expire(key, options[:expires_in]) if options[:expires_in] + rescuing { set(key, value, raw: true) } end - count.value if count - rescue Redis::BaseError - end - - def delete(key, options={}) - self.del(key) - rescue Redis::BaseError end end end diff --git a/lib/rack/attack/throttle.rb b/lib/rack/attack/throttle.rb index 5af953c0..0ec5f7aa 100644 --- a/lib/rack/attack/throttle.rb +++ b/lib/rack/attack/throttle.rb @@ -1,14 +1,19 @@ +# frozen_string_literal: true + module Rack class Attack class Throttle - MANDATORY_OPTIONS = [:limit, :period] + MANDATORY_OPTIONS = [:limit, :period].freeze + attr_reader :name, :limit, :period, :block, :type - def initialize(name, options, block) - @name, @block = name, block + + def initialize(name, options, &block) + @name = name + @block = block MANDATORY_OPTIONS.each do |opt| - raise ArgumentError.new("Must pass #{opt.inspect} option") unless options[opt] + raise ArgumentError, "Must pass #{opt.inspect} option" unless options[opt] end - @limit = options[:limit] + @limit = options[:limit] @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i @type = options.fetch(:type, :throttle) end @@ -17,32 +22,60 @@ def cache Rack::Attack.cache end - def [](req) - discriminator = block[req] + def matched_by?(request) + discriminator = discriminator_for(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 - key = "#{name}:#{discriminator}" - count = cache.count(key, current_period) + current_period = period_for(request) + current_limit = limit_for(request) + count = cache.count("#{name}:#{discriminator}", current_period) data = { - :count => count, - :period => current_period, - :limit => current_limit + discriminator: discriminator, + count: count, + period: current_period, + limit: current_limit, + epoch_time: cache.last_epoch_time } - (req.env['rack.attack.throttle_data'] ||= {})[name] = data + + annotate_request_with_throttle_data(request, 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) + annotate_request_with_matched_data(request, data) + Rack::Attack.instrument(request) end end end + + private + + def discriminator_for(request) + discriminator = block.call(request) + if discriminator && Rack::Attack.throttle_discriminator_normalizer + discriminator = Rack::Attack.throttle_discriminator_normalizer.call(discriminator) + end + discriminator + end + + def period_for(request) + period.respond_to?(:call) ? period.call(request) : period + end + + def limit_for(request) + limit.respond_to?(:call) ? limit.call(request) : limit + end + + def annotate_request_with_throttle_data(request, data) + (request.env['rack.attack.throttle_data'] ||= {})[name] = data + end + + def annotate_request_with_matched_data(request, data) + request.env['rack.attack.matched'] = name + request.env['rack.attack.match_discriminator'] = data[:discriminator] + request.env['rack.attack.match_type'] = type + request.env['rack.attack.match_data'] = data + end end end end diff --git a/lib/rack/attack/track.rb b/lib/rack/attack/track.rb index e0039e3b..2b3424cd 100644 --- a/lib/rack/attack/track.rb +++ b/lib/rack/attack/track.rb @@ -1,21 +1,24 @@ +# frozen_string_literal: true + module Rack class Attack class Track - extend Forwardable - attr_reader :filter - def initialize(name, options = {}, block) + def initialize(name, options = {}, &block) options[:type] = :track - if options[:limit] && options[:period] - @filter = Throttle.new(name, options, block) - else - @filter = Check.new(name, options, block) - end + @filter = + if options[:limit] && options[:period] + Throttle.new(name, options, &block) + else + Check.new(name, options, &block) + 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 06f4df77..0c534a0b 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 = '6.8.0' end end diff --git a/rack-attack.gemspec b/rack-attack.gemspec index 1fcdd42a..fe2e1566 100644 --- a/rack-attack.gemspec +++ b/rack-attack.gemspec @@ -1,8 +1,6 @@ -# -*- encoding: utf-8 -*- -lib = File.expand_path('../lib/', __FILE__) -$:.unshift lib unless $:.include?(lib) +# frozen_string_literal: true -require 'rack/attack/version' +require_relative 'lib/rack/attack/version' Gem::Specification.new do |s| s.name = 'rack-attack' @@ -13,24 +11,42 @@ Gem::Specification.new do |s| s.description = "A rack middleware for throttling and blocking abusive requests" s.email = "aaron@ktheory.com" - s.files = Dir.glob("{bin,lib}/**/*") + %w(Rakefile README.md) - s.homepage = 'http://github.com/kickstarter/rack-attack' + s.files = Dir.glob("{bin,lib}/**/*") + %w(Rakefile README.md LICENSE) + s.homepage = 'https://github.com/rack/rack-attack' s.rdoc_options = ["--charset=UTF-8"] s.require_paths = ["lib"] - s.summary = %q{Block & throttle abusive requests} + s.summary = 'Block & throttle abusive requests' s.test_files = Dir.glob("spec/**/*") - s.required_ruby_version = '>= 2.0.0' - - 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.metadata = { + "bug_tracker_uri" => "https://github.com/rack/rack-attack/issues", + "changelog_uri" => "https://github.com/rack/rack-attack/blob/main/CHANGELOG.md", + "source_code_uri" => "https://github.com/rack/rack-attack" + } + + s.required_ruby_version = '>= 2.4' + + s.add_runtime_dependency 'rack', ">= 1.0", "< 4" + + s.add_development_dependency 'appraisal', '~> 2.2' + s.add_development_dependency "bundler", ">= 1.17", "< 3.0" + s.add_development_dependency 'minitest', "~> 5.11" + s.add_development_dependency "minitest-stub-const", "~> 0.6" + s.add_development_dependency 'rack-test', "~> 2.0" + s.add_development_dependency 'rake', "~> 13.0" + s.add_development_dependency "rubocop", "1.12.1" + s.add_development_dependency "rubocop-minitest", "~> 0.11.1" + s.add_development_dependency "rubocop-performance", "~> 1.10.2" + s.add_development_dependency "rubocop-rake", "~> 0.5.1" + s.add_development_dependency "timecop", "~> 0.9.1" + + # byebug only works with MRI + if RUBY_ENGINE == "ruby" + s.add_development_dependency 'byebug', '~> 11.0' + end + + s.add_development_dependency "activesupport" + # Fix activesupport Direct version requirement on connection_pool + # can be removed once https://github.com/rails/rails/issues/56291 is ixed and released + s.add_development_dependency "connection_pool", "~> 2.5" end diff --git a/spec/acceptance/allow2ban_spec.rb b/spec/acceptance/allow2ban_spec.rb new file mode 100644 index 00000000..6de18d07 --- /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 00000000..4e41f29b --- /dev/null +++ b/spec/acceptance/blocking_ip_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Blocking an IP" do + let(:notifications) { [] } + + 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 "succeeds if IP is missing" do + get "/", {}, "REMOTE_ADDR" => "" + + assert_equal 200, last_response.status + end + + it "notifies when the request is blocked" do + ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/blocking_spec.rb b/spec/acceptance/blocking_spec.rb new file mode 100644 index 00000000..2ea9f67a --- /dev/null +++ b/spec/acceptance/blocking_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#blocklist" do + let(:notifications) { [] } + + before do + Rack::Attack.blocklist 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 + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_nil notification[:request].env["rack.attack.matched"] + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + end +end + +describe "#blocklist with name" do + let(:notifications) { [] } + + 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 + ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal "block 1.2.3.4", notification[:request].env["rack.attack.matched"] + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/blocking_subnet_spec.rb b/spec/acceptance/blocking_subnet_spec.rb new file mode 100644 index 00000000..9fe30598 --- /dev/null +++ b/spec/acceptance/blocking_subnet_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Blocking an IP subnet" do + let(:notifications) { [] } + + 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 + ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal :blocklist, notification[:request].env["rack.attack.match_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 00000000..45f8f5c3 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_allow2ban_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "minitest/stub_const" + +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 + + unless defined?(Rails) + it "gives semantic error if no store was configured" do + assert_raises(Rack::Attack::MissingStoreError) do + get "/scarce-resource" + end + end + end + + it "gives semantic error if store is missing #read method" do + raised_exception = nil + + fake_store_class = Class.new do + def write(key, value); end + + def increment(key, count, options = {}); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message + end + + it "gives semantic error if store is missing #write method" do + raised_exception = nil + + fake_store_class = Class.new do + def read(key); end + + def increment(key, count, options = {}); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message + end + + it "gives semantic error if store is missing #increment method" do + raised_exception = nil + + fake_store_class = Class.new do + def read(key); end + + def write(key, value); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/scarce-resource" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message + end + + it "works with any object that responds to #read, #write and #increment" do + fake_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 + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.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 +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 00000000..b46f9fe5 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_fail2ban_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "minitest/stub_const" + +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 + + unless defined?(Rails) + it "gives semantic error if no store was configured" do + assert_raises(Rack::Attack::MissingStoreError) do + get "/private-place" + end + end + end + + it "gives semantic error if store is missing #read method" do + raised_exception = nil + + fake_store_class = Class.new do + def write(key, value); end + + def increment(key, count, options = {}); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message + end + + it "gives semantic error if store is missing #write method" do + raised_exception = nil + + fake_store_class = Class.new do + def read(key); end + + def increment(key, count, options = {}); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message + end + + it "gives semantic error if store is missing #increment method" do + raised_exception = nil + + fake_store_class = Class.new do + def read(key); end + + def write(key, value); end + end + + Object.stub_const(:FakeStore, fake_store_class) do + Rack::Attack.cache.store = FakeStore.new + + raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do + get "/private-place" + end + end + + assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message + end + + it "works with any object that responds to #read, #write and #increment" do + fake_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 = fake_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 00000000..a89b8272 --- /dev/null +++ b/spec/acceptance/cache_store_config_for_throttle_spec.rb @@ -0,0 +1,52 @@ +# 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 + + unless defined?(Rails) + 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 + 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 00000000..66bb76a8 --- /dev/null +++ b/spec/acceptance/cache_store_config_with_rails_spec.rb @@ -0,0 +1,35 @@ +# 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 + + unless defined?(Rails) + 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 + 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 00000000..1ca127cc --- /dev/null +++ b/spec/acceptance/customizing_blocked_response_spec.rb @@ -0,0 +1,60 @@ +# 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_responder = lambda do |_req| + [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_responder = lambda do |req| + matched = req.env['rack.attack.matched'] + match_type = req.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 + + it "supports old style" do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 403, last_response.status + + silence_warnings do + Rack::Attack.blocklisted_response = lambda do |_env| + [503, {}, ["Blocked"]] + end + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 503, last_response.status + assert_equal "Blocked", last_response.body + 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 00000000..0990975e --- /dev/null +++ b/spec/acceptance/customizing_throttled_response_spec.rb @@ -0,0 +1,82 @@ +# 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_responder = lambda do |_req| + [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_responder = lambda do |req| + matched = req.env['rack.attack.matched'] + match_type = req.env['rack.attack.match_type'] + match_data = req.env['rack.attack.match_data'] + match_discriminator = req.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 + + it "supports old style" 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 + + silence_warnings do + Rack::Attack.throttled_response = lambda do |_req| + [503, {}, ["Throttled"]] + end + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 503, last_response.status + assert_equal "Throttled", last_response.body + 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 00000000..5449b90c --- /dev/null +++ b/spec/acceptance/extending_request_object_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Extending the request object" do + before do + Rack::Attack::Request.define_method :authorized? do + env["APIKey"] == "private-secret" + 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 + Rack::Attack::Request.undef_method :authorized? + 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 00000000..74c01f57 --- /dev/null +++ b/spec/acceptance/fail2ban_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "fail2ban" do + let(:notifications) { [] } + + 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 + + it "notifies when the request is blocked" do + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/" + + assert_equal 200, last_response.status + assert notifications.empty? + + get "/private-place" + + assert_equal 403, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + + get "/" + + assert_equal 200, last_response.status + assert notifications.empty? + + get "/private-place" + + assert_equal 403, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + + get "/" + + assert_equal 403, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal 'fail2ban pentesters', notification[:request].env["rack.attack.matched"] + assert_equal :blocklist, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/rails_middleware_spec.rb b/spec/acceptance/rails_middleware_spec.rb new file mode 100644 index 00000000..31dc6209 --- /dev/null +++ b/spec/acceptance/rails_middleware_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +if defined?(Rails::Application) + describe "Middleware for Rails" do + before do + @app = Class.new(Rails::Application) do + config.eager_load = false + config.logger = Logger.new(nil) # avoid creating the log/ directory automatically + config.cache_store = :null_store # avoid creating tmp/ directory for cache + end + end + + it "is used by default" do + @app.initialize! + assert @app.middleware.include?(Rack::Attack) + end + end +end diff --git a/spec/acceptance/safelisting_ip_spec.rb b/spec/acceptance/safelisting_ip_spec.rb new file mode 100644 index 00000000..e04ca6e5 --- /dev/null +++ b/spec/acceptance/safelisting_ip_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Safelist an IP" do + let(:notifications) { [] } + + 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 "forbids request if blocklist condition is true and safelist is false (missing IP)" do + get "/admin", {}, "REMOTE_ADDR" => "" + + 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 + ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/admin", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal :safelist, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/safelisting_spec.rb b/spec/acceptance/safelisting_spec.rb new file mode 100644 index 00000000..f00fada7 --- /dev/null +++ b/spec/acceptance/safelisting_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#safelist" do + let(:notifications) { [] } + + before do + Rack::Attack.blocklist do |request| + request.ip == "1.2.3.4" + end + + Rack::Attack.safelist 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 + ActiveSupport::Notifications.subscribe("rack.attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_nil notification[:request].env["rack.attack.matched"] + assert_equal :safelist, notification[:request].env["rack.attack.match_type"] + end +end + +describe "#safelist with name" do + let(:notifications) { [] } + + 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 + ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal "safe path", notification[:request].env["rack.attack.matched"] + assert_equal :safelist, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/safelisting_subnet_spec.rb b/spec/acceptance/safelisting_subnet_spec.rb new file mode 100644 index 00000000..baeb7e46 --- /dev/null +++ b/spec/acceptance/safelisting_subnet_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "Safelisting an IP subnet" do + let(:notifications) { [] } + + 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 + ActiveSupport::Notifications.subscribe("safelist.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/admin", {}, "REMOTE_ADDR" => "5.6.0.0" + + assert_equal 200, last_response.status + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal :safelist, notification[:request].env["rack.attack.match_type"] + 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 00000000..ccfe3db7 --- /dev/null +++ b/spec/acceptance/stores/active_support_mem_cache_store_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) + require_relative "../../support/cache_store_helper" + + describe "ActiveSupport::Cache::MemCacheStore as a cache backend" do + before do + Rack::Attack.cache.store = if ActiveSupport.gem_version >= Gem::Version.new("7.2.0") + ActiveSupport::Cache::MemCacheStore.new(pool: true) + else + ActiveSupport::Cache::MemCacheStore.new(pool_size: 2) + end + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) + 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 00000000..4ed81e7f --- /dev/null +++ b/spec/acceptance/stores/active_support_memory_store_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require_relative "../../support/cache_store_helper" + +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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) +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 00000000..9cf1636e --- /dev/null +++ b/spec/acceptance/stores/active_support_redis_cache_store_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +should_run = + defined?(::Redis) && + Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && + defined?(::ActiveSupport::Cache::RedisCacheStore) + +if should_run + require_relative "../../support/cache_store_helper" + + describe "ActiveSupport::Cache::RedisCacheStore as a cache backend" do + before do + Rack::Attack.cache.store = if ActiveSupport.gem_version >= Gem::Version.new("7.2.0") + ActiveSupport::Cache::RedisCacheStore.new(pool: true) + else + ActiveSupport::Cache::RedisCacheStore.new(pool_size: 2) + end + end + + after do + Rack::Attack.cache.store.clear + end + + it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) + 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 00000000..2b273740 --- /dev/null +++ b/spec/acceptance/stores/dalli_client_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Dalli) + require_relative "../../support/cache_store_helper" + require "dalli" + + 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) + end + + 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( + fetch_from_store: ->(key) { Rack::Attack.cache.store.with { |client| client.fetch(key) } } + ) + end +end diff --git a/spec/acceptance/stores/redis_spec.rb b/spec/acceptance/stores/redis_spec.rb new file mode 100644 index 00000000..bf68bc23 --- /dev/null +++ b/spec/acceptance/stores/redis_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +if defined?(::Redis) + require_relative "../../support/cache_store_helper" + + describe "Plain redis as a cache backend" do + before do + Rack::Attack.cache.store = Redis.new + end + + after do + Rack::Attack.cache.store.flushdb + end + + it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.get(key) }) + 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 00000000..83d0e659 --- /dev/null +++ b/spec/acceptance/stores/redis_store_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" +require_relative "../../support/cache_store_helper" + +if defined?(::Redis::Store) + describe "Redis::Store 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) + end +end diff --git a/spec/acceptance/throttling_spec.rb b/spec/acceptance/throttling_spec.rb new file mode 100644 index 00000000..ca81d1c5 --- /dev/null +++ b/spec/acceptance/throttling_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "#throttle" do + let(:notifications) { [] } + + 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_nil 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 "returns correct Retry-After header if enabled" do + Rack::Attack.throttled_response_retry_after_header = true + + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + + Timecop.freeze(Time.at(0)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal 200, last_response.status + end + + Timecop.freeze(Time.at(25)) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + assert_equal "35", last_response.headers["Retry-After"] + 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 + + ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert_equal 200, last_response.status + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 200, last_response.status + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 429, last_response.status + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal "by ip", notification[:request].env["rack.attack.matched"] + assert_equal :throttle, notification[:request].env["rack.attack.match_type"] + assert_equal 60, notification[:request].env["rack.attack.match_data"][:period] + assert_equal 1, notification[:request].env["rack.attack.match_data"][:limit] + assert_equal 2, notification[:request].env["rack.attack.match_data"][:count] + assert_equal "1.2.3.4", notification[:request].env["rack.attack.match_discriminator"] + end +end diff --git a/spec/acceptance/track_spec.rb b/spec/acceptance/track_spec.rb new file mode 100644 index 00000000..62d1551a --- /dev/null +++ b/spec/acceptance/track_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe "#track" do + let(:notifications) { [] } + + 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 + + ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal "ip 1.2.3.4", notification[:request].env["rack.attack.matched"] + assert_equal :track, notification[:request].env["rack.attack.match_type"] + end +end diff --git a/spec/acceptance/track_throttle_spec.rb b/spec/acceptance/track_throttle_spec.rb new file mode 100644 index 00000000..e6aa553a --- /dev/null +++ b/spec/acceptance/track_throttle_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" +require "timecop" + +describe "#track with throttle-ish options" do + let(:notifications) { [] } + + 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 + + ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) + end + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert notifications.empty? + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "5.6.7.8" + + assert notifications.empty? + + assert_equal 200, last_response.status + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert_equal 1, notifications.size + notification = notifications.pop + assert_equal "by ip", notification[:request].env["rack.attack.matched"] + assert_equal :track, notification[:request].env["rack.attack.match_type"] + + assert_equal 200, last_response.status + + Timecop.travel(60) do + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + + assert notifications.empty? + + assert_equal 200, last_response.status + end + end +end diff --git a/spec/allow2ban_spec.rb b/spec/allow2ban_spec.rb index 3e3cf479..105e0ad0 100644 --- a/spec/allow2ban_spec.rb +++ b/spec/allow2ban_spec.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require_relative 'spec_helper' + describe 'Rack::Attack.Allow2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @@ -6,9 +9,10 @@ @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 @@ -16,25 +20,28 @@ describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' - last_response.status.must_equal 200 + + _(last_response.status).must_equal 200 end end describe 'making qualifying request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } + it 'succeeds' do - last_response.status.must_equal 200 + _(last_response.status).must_equal 200 end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 1 + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" + + _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:allow2ban:1.2.3.4" - @cache.store.read(key).must_be_nil + _(@cache.store.read(key)).must_be_nil end end @@ -46,19 +53,18 @@ end it 'succeeds' do - last_response.status.must_equal 200 + _(last_response.status).must_equal 200 end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end - end end end @@ -73,7 +79,8 @@ describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' - last_response.status.must_equal 200 + + _(last_response.status).must_equal 200 end end @@ -83,17 +90,17 @@ end it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end end @@ -103,19 +110,18 @@ end it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end end - end end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb new file mode 100644 index 00000000..78a4b34d --- /dev/null +++ b/spec/configuration_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +describe Rack::Attack::Configuration do + subject { Rack::Attack::Configuration.new } + + describe 'attributes' do + it 'exposes the safelists attribute' do + _(subject.safelists).must_equal({}) + end + + it 'exposes the blocklists attribute' do + _(subject.blocklists).must_equal({}) + end + + it 'exposes the throttles attribute' do + _(subject.throttles).must_equal({}) + end + + it 'exposes the tracks attribute' do + _(subject.tracks).must_equal({}) + end + + it 'exposes the anonymous_blocklists attribute' do + _(subject.anonymous_blocklists).must_equal([]) + end + + it 'exposes the anonymous_safelists attribute' do + _(subject.anonymous_safelists).must_equal([]) + end + end +end diff --git a/spec/fail2ban_spec.rb b/spec/fail2ban_spec.rb index 0f24524a..6a9d9bcf 100644 --- a/spec/fail2ban_spec.rb +++ b/spec/fail2ban_spec.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require_relative 'spec_helper' + describe 'Rack::Attack.Fail2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @@ -6,9 +9,10 @@ @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 @@ -16,25 +20,26 @@ describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' - last_response.status.must_equal 200 + _(last_response.status).must_equal 200 end end describe 'making failing request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } + it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 1 + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:fail2ban:1.2.3.4" - @cache.store.read(key).must_be_nil + _(@cache.store.read(key)).must_be_nil end end @@ -46,17 +51,17 @@ end it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'increases fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end end @@ -68,16 +73,16 @@ end it 'succeeds' do - last_response.status.must_equal 200 + _(last_response.status).must_equal 200 end it 'resets fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" - @cache.store.read(key).must_equal nil + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" + assert_nil @cache.store.read(key) end it 'IP is not banned' do - Rack::Attack::Fail2Ban.banned?('1.2.3.4').must_equal false + _(Rack::Attack::Fail2Ban.banned?('1.2.3.4')).must_equal false end end end @@ -93,7 +98,8 @@ describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' - last_response.status.must_equal 200 + + _(last_response.status).must_equal 200 end end @@ -103,17 +109,17 @@ end it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end end @@ -123,19 +129,18 @@ end it 'fails' do - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end it 'does not increase fail count' do - key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4" - @cache.store.read(key).must_equal 2 + key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" + _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" - @cache.store.read(key).must_equal 1 + _(@cache.store.read(key)).must_equal 1 end end - end end diff --git a/spec/integration/offline_spec.rb b/spec/integration/offline_spec.rb index 95598d42..85429a42 100644 --- a/spec/integration/offline_spec.rb +++ b/spec/integration/offline_spec.rb @@ -1,10 +1,9 @@ +# frozen_string_literal: true + require 'active_support/cache' -require 'redis-activesupport' -require 'dalli' require_relative '../spec_helper' OfflineExamples = Minitest::SharedExamples.new do - it 'should write' do @cache.write('cache-test-key', 'foobar', 1) end @@ -14,34 +13,68 @@ end it 'should count' do - @cache.send(:do_count, 'rack::attack::cache-test-key', 1) + @cache.count('cache-test-key', 1) end + it 'should delete' do + @cache.delete('cache-test-key') + end +end + +if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) && Redis::VERSION >= '4' + describe 'when Redis is offline' do + include OfflineExamples + + before do + @cache = Rack::Attack::Cache.new + # Use presumably unused port for Redis client + @cache.store = ActiveSupport::Cache::RedisCacheStore.new(host: '127.0.0.1', port: 3333) + end + end end -describe 'when Redis is offline' do - include OfflineExamples +if defined?(::Dalli) + describe 'when Memcached 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 + Dalli.logger.level = Logger::FATAL + @cache = Rack::Attack::Cache.new + @cache.store = Dalli::Client.new('127.0.0.1:22122') + end + + after do + Dalli.logger.level = Logger::INFO + end + end end -describe 'when Memcached is offline' do - include OfflineExamples +if defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) + describe 'when Memcached is offline' do + include OfflineExamples + + before do + Dalli.logger.level = Logger::FATAL - before { - Dalli.logger.level = Logger::FATAL + @cache = Rack::Attack::Cache.new + @cache.store = ActiveSupport::Cache::MemCacheStore.new('127.0.0.1:22122') + end - @cache = Rack::Attack::Cache.new - @cache.store = Dalli::Client.new('127.0.0.1:22122') - } + after do + Dalli.logger.level = Logger::INFO + end + end +end - after { - Dalli.logger.level = Logger::INFO - } +if defined?(Redis) + describe 'when Redis is offline' do + include OfflineExamples + before do + @cache = Rack::Attack::Cache.new + # Use presumably unused port for Redis client + @cache.store = Redis.new(host: '127.0.0.1', port: 3333) + 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 6eb27eff..00000000 --- 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 - ] - - 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) - @cache.read('cache-test-key').must_equal nil - 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) - store.read(period_key).must_equal nil - end - end - end - - end -end diff --git a/spec/rack_attack_dalli_proxy_spec.rb b/spec/rack_attack_dalli_proxy_spec.rb index 50a2b9ce..7eb23f00 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 00000000..d2291f77 --- /dev/null +++ b/spec/rack_attack_instrumentation_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "spec_helper" +require 'active_support' +require 'active_support/subscriber' + +class CustomSubscriber < ActiveSupport::Subscriber + @notification_count = 0 + + class << self + attr_accessor :notification_count + end + + def throttle(_event) + self.class.notification_count += 1 + 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("rack_attack") + 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } + end + end + + it 'should instrument without error' do + _(last_response.status).must_equal 429 + assert_equal 1, CustomSubscriber.notification_count + end + end +end diff --git a/spec/rack_attack_path_normalizer_spec.rb b/spec/rack_attack_path_normalizer_spec.rb index 1c5b66e9..52fae263 100644 --- a/spec/rack_attack_path_normalizer_spec.rb +++ b/spec/rack_attack_path_normalizer_spec.rb @@ -1,17 +1,19 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe Rack::Attack::PathNormalizer do subject { Rack::Attack::PathNormalizer } it 'should have a normalize_path method' do - subject.normalize_path('/foo').must_equal '/foo' + _(subject.normalize_path('/foo')).must_equal '/foo' end describe 'FallbackNormalizer' do subject { Rack::Attack::FallbackPathNormalizer } it '#normalize_path does not change the path' do - subject.normalize_path('').must_equal '' + _(subject.normalize_path('')).must_equal '' end end end diff --git a/spec/rack_attack_request_spec.rb b/spec/rack_attack_request_spec.rb index cc617aed..8f27301a 100644 --- a/spec/rack_attack_request_spec.rb +++ b/spec/rack_attack_request_spec.rb @@ -1,12 +1,12 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack' do describe 'helpers' do before do - class Rack::Attack::Request - def remote_ip - ip - end + Rack::Attack::Request.define_method :remote_ip do + ip end Rack::Attack.safelist('valid IP') do |req| @@ -14,6 +14,6 @@ def remote_ip end end - allow_ok_requests + it_allows_ok_requests end end diff --git a/spec/rack_attack_reset_spec.rb b/spec/rack_attack_reset_spec.rb new file mode 100644 index 00000000..b9a94e39 --- /dev/null +++ b/spec/rack_attack_reset_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative "spec_helper" + +describe "Rack::Attack.reset!" do + it "raises an error when is not supported by cache store" do + Rack::Attack.cache.store = Class.new + assert_raises(Rack::Attack::IncompatibleStoreError) do + Rack::Attack.reset! + end + end + + if defined?(Redis) + it "should delete rack attack keys" do + redis = Redis.new + redis.set("key", "value") + redis.set("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = redis + Rack::Attack.reset! + + _(redis.get("key")).must_equal "value" + _(redis.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + end + + if defined?(Redis::Store) + it "should delete rack attack keys" do + redis_store = Redis::Store.new + redis_store.set("key", "value") + redis_store.set("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = redis_store + Rack::Attack.reset! + + _(redis_store.get("key")).must_equal "value" + _(redis_store.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + end + + if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) + it "should delete rack attack keys" do + redis_cache_store = ActiveSupport::Cache::RedisCacheStore.new + redis_cache_store.write("key", "value") + redis_cache_store.write("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = redis_cache_store + Rack::Attack.reset! + + _(redis_cache_store.read("key")).must_equal "value" + _(redis_cache_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + + describe "with a namespaced cache" do + it "should delete rack attack keys" do + redis_cache_store = ActiveSupport::Cache::RedisCacheStore.new(namespace: "ns") + redis_cache_store.write("key", "value") + redis_cache_store.write("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = redis_cache_store + Rack::Attack.reset! + + _(redis_cache_store.read("key")).must_equal "value" + _(redis_cache_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + end + end + + if defined?(ActiveSupport::Cache::MemoryStore) + it "should delete rack attack keys" do + memory_store = ActiveSupport::Cache::MemoryStore.new + memory_store.write("key", "value") + memory_store.write("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = memory_store + Rack::Attack.reset! + + _(memory_store.read("key")).must_equal "value" + _(memory_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + + describe "with a namespaced cache" do + it "should delete rack attack keys" do + memory_store = ActiveSupport::Cache::MemoryStore.new(namespace: "ns") + memory_store.write("key", "value") + memory_store.write("#{Rack::Attack.cache.prefix}::key", "value") + Rack::Attack.cache.store = memory_store + Rack::Attack.reset! + + _(memory_store.read("key")).must_equal "value" + _(memory_store.read("#{Rack::Attack.cache.prefix}::key")).must_be_nil + end + end + end +end diff --git a/spec/rack_attack_spec.rb b/spec/rack_attack_spec.rb index 8b2f942c..acc210a6 100644 --- a/spec/rack_attack_spec.rb +++ b/spec/rack_attack_spec.rb @@ -1,99 +1,106 @@ +# 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 + if Rack::Attack::PathNormalizer == Rack::Attack::FallbackPathNormalizer + skip "Normalization is only present on Rails" + end + get '/foo/' - last_response.status.must_equal 403 + _(last_response.status).must_equal 403 end end 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 - } + it 'has a blocklist' do + _(Rack::Attack.blocklists.key?("ip #{@bad_ip}")).must_equal true + end describe "a bad request" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip } + it "should return a blocklist response" do - get '/', {}, 'REMOTE_ADDR' => @bad_ip - last_response.status.must_equal 403 - last_response.body.must_equal "Forbidden\n" + _(last_response.status).must_equal 403 + _(last_response.body).must_equal "Forbidden\n" end + it "should tag the env" do - last_request.env['rack.attack.matched'].must_equal "ip #{@bad_ip}" - last_request.env['rack.attack.match_type'].must_equal :blocklist + _(last_request.env['rack.attack.matched']).must_equal "ip #{@bad_ip}" + _(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 - } + it('has a safelist') { Rack::Attack.safelists.key?("good ua") } describe "with a request match both safelist & blocklist" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua } + it "should allow safelists before blocklists" do - get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua - last_response.status.must_equal 200 + _(last_response.status).must_equal 200 end + it "should tag the env" do - last_request.env['rack.attack.matched'].must_equal 'good ua' - last_request.env['rack.attack.match_type'].must_equal :safelist + _(last_request.env['rack.attack.matched']).must_equal 'good ua' + _(last_request.env['rack.attack.match_type']).must_equal :safelist end end end - describe '#blocklisted_response' do + describe '#blocklisted_responder' 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 - + _(Rack::Attack.blocklisted_responder).must_respond_to :call end end - describe '#throttled_response' do + describe '#throttled_responder' do it 'should exist' do - Rack::Attack.throttled_response.must_respond_to :call + _(Rack::Attack.throttled_responder).must_respond_to :call end end - end + describe 'enabled' do + it 'should be enabled by default' do + _(Rack::Attack.enabled).must_equal true + end + + it 'should directly pass request when disabled' do + bad_ip = '1.2.3.4' + Rack::Attack.blocklist("ip #{bad_ip}") { |req| req.ip == bad_ip } + + get '/', {}, 'REMOTE_ADDR' => bad_ip + _(last_response.status).must_equal 403 + + prev_enabled = Rack::Attack.enabled + begin + Rack::Attack.enabled = false + get '/', {}, 'REMOTE_ADDR' => bad_ip + _(last_response.status).must_equal 200 + ensure + Rack::Attack.enabled = prev_enabled + end + end + end end diff --git a/spec/rack_attack_throttle_spec.rb b/spec/rack_attack_throttle_spec.rb index d64a27dc..1bf7f32f 100644 --- a/spec/rack_attack_throttle_spec.rb +++ b/spec/rack_attack_throttle_spec.rb @@ -1,109 +1,211 @@ +# frozen_string_literal: true + require_relative 'spec_helper' +require_relative 'support/freeze_time_helper' + describe 'Rack::Attack.throttle' do before do - @period = 60 # Use a long period; failures due to cache key rotation less likely + @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - Rack::Attack.throttle('ip/sec', :limit => 1, :period => @period) { |req| req.ip } + Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |req| req.ip } end - it('should have a throttle'){ Rack::Attack.throttles.key?('ip/sec') } - allow_ok_requests + it('should have a throttle') { Rack::Attack.throttles.key?('ip/sec') } + + 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" - Rack::Attack.cache.store.read(key).must_equal 1 + within_same_period do + get '/', {}, 'REMOTE_ADDR' => '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 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } - last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data + get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' + + data = { + count: 1, + limit: 1, + period: @period, + epoch_time: Rack::Attack.cache.last_epoch_time.to_i, + discriminator: "1.2.3.4" + } + + _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end + describe "with 2 requests" do before do - 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } + within_same_period do + 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } + end end + it 'should block the last request' do - last_response.status.must_equal 429 + _(last_response.status).must_equal 429 end + 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_discriminator'].must_equal('1.2.3.4') - end - it 'should set a Retry-After header' do - last_response.headers['Retry-After'].must_equal @period.to_s + _(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, + epoch_time: Rack::Attack.cache.last_epoch_time.to_i, + discriminator: "1.2.3.4" + ) + + _(last_request.env['rack.attack.match_discriminator']).must_equal('1.2.3.4') end end end 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 + @period = 60 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" - Rack::Attack.cache.store.read(key).must_equal 1 + within_same_period do + get '/', {}, 'REMOTE_ADDR' => '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 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } - last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data + get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' + data = { + count: 1, + limit: 1, + period: @period, + epoch_time: Rack::Attack.cache.last_epoch_time.to_i, + discriminator: "1.2.3.4" + } + + _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end 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 + @period = 60 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" - Rack::Attack.cache.store.read(key).must_equal 1 + within_same_period do + get '/', {}, 'REMOTE_ADDR' => '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 end it 'should populate throttle data' do - data = { :count => 1, :limit => 1, :period => @period } - last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data + get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' + + data = { + count: 1, + limit: 1, + period: @period, + epoch_time: Rack::Attack.cache.last_epoch_time.to_i, + discriminator: "1.2.3.4" + } + + _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end -describe 'Rack::Attack.throttle with block retuning nil' do +describe 'Rack::Attack.throttle with block returning nil' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new - Rack::Attack.throttle('ip/sec', :limit => 1, :period => @period) { |_| nil } + 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" - Rack::Attack.cache.store.read(key).must_equal nil + within_same_period do + get '/', {}, 'REMOTE_ADDR' => '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 end it 'should not populate throttle data' do - last_request.env['rack.attack.throttle_data'].must_equal nil + get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' + assert_nil last_request.env['rack.attack.throttle_data'] + end + end +end + +describe 'Rack::Attack.throttle with throttle_discriminator_normalizer' do + before do + @period = 60 + @emails = [ + "person@example.com", + "PERSON@example.com ", + " person@example.com\r\n ", + ] + Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new + Rack::Attack.throttle('logins/email', limit: 4, period: @period) do |req| + if req.path == '/login' && req.post? + req.params['email'] + end end end -end \ No newline at end of file + + it 'should not differentiate requests when throttle_discriminator_normalizer is enabled' do + within_same_period do + post_logins + key = "rack::attack:#{Time.now.to_i / @period}:logins/email:person@example.com" + _(Rack::Attack.cache.store.read(key)).must_equal 3 + end + end + + it 'should differentiate requests when throttle_discriminator_normalizer is disabled' do + begin + prev = Rack::Attack.throttle_discriminator_normalizer + Rack::Attack.throttle_discriminator_normalizer = nil + + within_same_period do + post_logins + @emails.each do |email| + key = "rack::attack:#{Time.now.to_i / @period}:logins/email:#{email}" + _(Rack::Attack.cache.store.read(key)).must_equal 1 + end + end + ensure + Rack::Attack.throttle_discriminator_normalizer = prev + end + end + + def post_logins + @emails.each do |email| + post '/login', email: email + end + end +end diff --git a/spec/rack_attack_track_spec.rb b/spec/rack_attack_track_spec.rb index 9f9ca9cc..0013c15b 100644 --- a/spec/rack_attack_track_spec.rb +++ b/spec/rack_attack_track_spec.rb @@ -1,58 +1,53 @@ +# frozen_string_literal: true + require_relative 'spec_helper' describe 'Rack::Attack.track' do - class Counter - def self.incr - @counter += 1 - end - - def self.reset - @counter = 0 - end - - def self.check - @counter - end - end + let(:notifications) { [] } 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 '/' - last_request.env['rack.attack.matched'].must_equal 'everything' - last_request.env['rack.attack.match_type'].must_equal :track + + _(last_request.env['rack.attack.matched']).must_equal 'everything' + _(last_request.env['rack.attack.match_type']).must_equal :track end describe "with a notification subscriber and two tracks" 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| - Counter.incr + ActiveSupport::Notifications.subscribe("track.rack_attack") do |_name, _start, _finish, _id, payload| + notifications.push(payload) end + get "/" end it "should notify twice" do - Counter.check.must_equal 2 + _(notifications.size).must_equal 2 end end 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 118d1fe1..894de6a1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,41 +1,63 @@ -require "rubygems" +# frozen_string_literal: true + require "bundler/setup" +require "logger" require "minitest/autorun" require "minitest/pride" require "rack/test" -require 'active_support' -require 'action_dispatch' - -# Load Journey for Rails 3.2 -require 'journey' if ActionPack::VERSION::MAJOR == 3 - +require "active_support" require "rack/attack" -begin - require 'pry' +if RUBY_ENGINE == "ruby" + require "byebug" +end + +def safe_require(name) + require name rescue LoadError - #nothing to do here + nil end -class MiniTest::Spec +safe_require "connection_pool" +safe_require "dalli" +safe_require "rails" +safe_require "redis" +safe_require "redis-store" +class Minitest::Spec include Rack::Test::Methods - after { Rack::Attack.clear! } + before do + if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) && Rails.cache.respond_to?(:clear) + Rails.cache.clear + end + end + + after do + Rack::Attack.clear_configuration + Rack::Attack.instance_variable_set(:@cache, nil) + end def app - Rack::Builder.new { + Rack::Builder.new do + # Use Rack::Lint to test that rack-attack is complying with the rack spec + use Rack::Lint + # Intentionally added twice to test idempotence property use Rack::Attack - run lambda {|env| [200, {}, ['Hello World']]} - }.to_app + use Rack::Attack + use Rack::Lint + + run lambda { |_env| [200, {}, ['Hello World']] } + end.to_app end - def self.allow_ok_requests + 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 - last_response.body.must_equal 'Hello World' + + _(last_response.status).must_equal 200 + _(last_response.body).must_equal 'Hello World' end end end diff --git a/spec/support/cache_store_helper.rb b/spec/support/cache_store_helper.rb new file mode 100644 index 00000000..8295ac00 --- /dev/null +++ b/spec/support/cache_store_helper.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative 'freeze_time_helper' + +class Minitest::Spec + def self.it_works_for_cache_backed_features(options) + fetch_from_store = options.fetch(:fetch_from_store) + + it "works for throttle" do + Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| + request.ip + end + + within_same_period 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 + + 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 + + within_same_period do + 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 + + 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 + + within_same_period do + 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 + + it "doesn't leak keys" do + Rack::Attack.throttle("by ip", limit: 1, period: 1) do |request| + request.ip + end + + key = nil + + within_same_period do + key = "rack::attack:#{Time.now.to_i}:by ip:1.2.3.4" + + get "/", {}, "REMOTE_ADDR" => "1.2.3.4" + end + + assert fetch_from_store.call(key) + + sleep 2.1 + + assert_nil fetch_from_store.call(key) + end + end +end diff --git a/spec/support/freeze_time_helper.rb b/spec/support/freeze_time_helper.rb new file mode 100644 index 00000000..462c877a --- /dev/null +++ b/spec/support/freeze_time_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "timecop" + +class Minitest::Spec + def within_same_period(&block) + Timecop.freeze(&block) + end +end