diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c7950f0f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,115 @@ +name: build + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-20.04 + services: + redis: + image: redis + ports: + - 6379:6379 + memcached: + image: memcached + ports: + - 11211:11211 + strategy: + matrix: + ruby: + - '3.2' + - '3.1' + - '3.0' + - '2.7' + - '2.6' + - '2.5' + gemfile: + - rack_3 + - rack_2 + - rack_1 + - rails_7_1 + - rails_7_0 + - rails_6_1 + - rails_6_0 + - rails_5_2 + - dalli3 + - dalli2 + - redis_5 + - redis_4 + - connection_pool_dalli + - active_support_7_1_redis_cache_store + - active_support_7_1_redis_cache_store_pooled + - active_support_7_0_redis_cache_store + - active_support_7_0_redis_cache_store_pooled + - active_support_6_redis_cache_store + - active_support_6_redis_cache_store_pooled + - active_support_5_redis_cache_store + - active_support_5_redis_cache_store_pooled + - redis_store + exclude: + - gemfile: rack_1 + ruby: '3.2' + - gemfile: rails_5_2 + ruby: '3.2' + - gemfile: active_support_5_redis_cache_store + ruby: '3.2' + - gemfile: active_support_5_redis_cache_store_pooled + ruby: '3.2' + - gemfile: dalli2 + ruby: '3.2' + - gemfile: rack_1 + ruby: '3.1' + - gemfile: rails_5_2 + ruby: '3.1' + - gemfile: active_support_5_redis_cache_store + ruby: '3.1' + - gemfile: active_support_5_redis_cache_store_pooled + ruby: '3.1' + - gemfile: dalli2 + ruby: '3.1' + - gemfile: rack_1 + ruby: '3.0' + - gemfile: rails_5_2 + ruby: '3.0' + - gemfile: active_support_5_redis_cache_store + ruby: '3.0' + - gemfile: active_support_5_redis_cache_store_pooled + ruby: '3.0' + - gemfile: dalli2 + ruby: '3.0' + - gemfile: rack_1 + ruby: '2.7' + - gemfile: rails_7_0 + ruby: '2.6' + - gemfile: rails_7_0 + ruby: '2.5' + - gemfile: active_support_7_0_redis_cache_store + ruby: '2.6' + - gemfile: active_support_7_0_redis_cache_store + ruby: '2.5' + - gemfile: active_support_7_0_redis_cache_store_pooled + ruby: '2.6' + - gemfile: active_support_7_0_redis_cache_store_pooled + ruby: '2.5' + - gemfile: rails_7_1 + ruby: '2.6' + - gemfile: rails_7_1 + ruby: '2.5' + - gemfile: active_support_7_1_redis_cache_store + ruby: '2.6' + - gemfile: active_support_7_1_redis_cache_store + ruby: '2.5' + - gemfile: active_support_7_1_redis_cache_store_pooled + ruby: '2.6' + - gemfile: active_support_7_1_redis_cache_store_pooled + ruby: '2.5' + env: + BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake + diff --git a/.rubocop.yml b/.rubocop.yml index 28f8502a..865fea87 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,16 @@ require: + - rubocop-minitest - rubocop-performance + - rubocop-rake inherit_mode: merge: - Exclude AllCops: - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.4 DisabledByDefault: true + NewCops: disable Exclude: - "examples/instrumentation.rb" - "gemfiles/**/*" @@ -21,17 +24,32 @@ Gemspec: 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" -Metrics/LineLength: - Max: 120 - Performance: Enabled: true @@ -40,10 +58,6 @@ Security: Style/BlockDelimiters: Enabled: true - IgnoredMethods: [] # Workaround rubocop bug: https://github.com/rubocop-hq/rubocop/issues/6179 - -Style/BracesAroundHashParameters: - Enabled: true Style/ClassAndModuleChildren: Enabled: true @@ -68,6 +82,12 @@ Style/FrozenStringLiteralComment: Style/HashSyntax: Enabled: true +Style/MultilineTernaryOperator: + Enabled: true + +Style/NestedTernaryOperator: + Enabled: true + Style/OptionalArguments: Enabled: true @@ -83,6 +103,9 @@ Style/RedundantBegin: Style/RedundantFreeze: Enabled: true +Style/RedundantPercentQ: + Enabled: true + Style/RedundantSelf: Enabled: true @@ -94,6 +117,3 @@ Style/SingleLineMethods: Style/SpecialGlobalVars: Enabled: true - -Style/UnneededPercentQ: - Enabled: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ddfaf94a..00000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -dist: xenial -language: ruby -cache: bundler - -rvm: - - ruby-head - - 2.6.5 - - 2.5.7 - - 2.4.9 - - 2.3.8 - -before_install: - - gem update --system - - gem install bundler -v "~> 2.0" - -gemfile: - - gemfiles/rack_2_0.gemfile - - gemfiles/rack_1_6.gemfile - - gemfiles/rails_6_0.gemfile - - gemfiles/rails_5_2.gemfile - - gemfiles/rails_5_1.gemfile - - gemfiles/rails_4_2.gemfile - - gemfiles/dalli2.gemfile - - gemfiles/redis_4.gemfile - - gemfiles/redis_3.gemfile - - gemfiles/connection_pool_dalli.gemfile - - gemfiles/active_support_redis_cache_store.gemfile - - gemfiles/active_support_redis_cache_store_pooled.gemfile - - gemfiles/redis_store.gemfile - - gemfiles/active_support_redis_store.gemfile - -matrix: - allow_failures: - - rvm: ruby-head - exclude: - - gemfile: gemfiles/rails_6_0.gemfile - rvm: 2.4.9 - - gemfile: gemfiles/rails_6_0.gemfile - rvm: 2.3.8 - fast_finish: true - -services: - - redis - - memcached diff --git a/Appraisals b/Appraisals index 834d2018..f68e8b7f 100644 --- a/Appraisals +++ b/Appraisals @@ -1,22 +1,38 @@ # frozen_string_literal: true -appraise "rack_2_0" do - gem "rack", "~> 2.0.4" +appraise "rack_3" do + gem "rack", "~> 3.0" end -appraise "rack_1_6" do +appraise "rack_2" do + gem "rack", "~> 2.0" +end + +appraise "rack_1" do # Override activesupport and actionpack version constraints by making # it more loose so it's compatible with rack 1.6.x gem "actionpack", ">= 4.2" gem "activesupport", ">= 4.2" - gem "rack", "~> 1.6.9" + gem "rack", "~> 1.6" # Override rack-test version constraint by making it more loose # so it's compatible with actionpack 4.2.x gem "rack-test", ">= 0.6" end +appraise 'rails_7-1' do + gem 'railties', '~> 7.1.0' +end + +appraise 'rails_7-0' do + gem 'railties', '~> 7.0.0' +end + +appraise 'rails_6-1' do + gem 'railties', '~> 6.1.0' +end + appraise 'rails_6-0' do gem 'railties', '~> 6.0.0' end @@ -25,50 +41,71 @@ appraise 'rails_5-2' do gem 'railties', '~> 5.2.0' end -appraise 'rails_5-1' do - gem 'railties', '~> 5.1.0' +appraise 'dalli2' do + gem 'dalli', '~> 2.0' end -appraise 'rails_4-2' do - gem 'railties', '~> 4.2.0' - - # Override rack-test version constraint by making it more loose - # so it's compatible with actionpack 4.2.x - gem "rack-test", ">= 0.6" +appraise 'dalli3' do + gem 'dalli', '~> 3.0' end -appraise 'dalli2' do - gem 'dalli', '~> 2.0' +appraise 'redis_5' do + gem 'redis', '~> 5.0' end appraise 'redis_4' do gem 'redis', '~> 4.0' end -appraise 'redis_3' do - gem 'redis', '~> 3.3' +appraise "connection_pool_dalli" do + gem "connection_pool", "~> 2.2" + gem "dalli", "~> 3.0" end -appraise "connection_pool_dalli" do +appraise "active_support_7-1_redis_cache_store" do + gem "activesupport", "~> 7.1.0" + gem "redis", "~> 5.0" +end + +appraise "active_support_7-1_redis_cache_store_pooled" do + gem "activesupport", "~> 7.1.0" + gem "connection_pool", "~> 2.2" + gem "redis", "~> 5.0" +end + +appraise "active_support_7-0_redis_cache_store" do + gem "activesupport", "~> 7.0.0" + gem "redis", "~> 5.0" +end + +appraise "active_support_7-0_redis_cache_store_pooled" do + gem "activesupport", "~> 7.0.0" + gem "connection_pool", "~> 2.2" + gem "redis", "~> 5.0" +end + +appraise "active_support_6_redis_cache_store" do + gem "activesupport", "~> 6.1.0" + gem "redis", "~> 5.0" +end + +appraise "active_support_6_redis_cache_store_pooled" do + gem "activesupport", "~> 6.1.0" gem "connection_pool", "~> 2.2" - gem "dalli", "~> 2.7" + gem "redis", "~> 5.0" end -appraise "active_support_redis_cache_store" do +appraise "active_support_5_redis_cache_store" do gem "activesupport", "~> 5.2.0" - gem "redis", "~> 4.0" + gem "redis", "~> 5.0" end -appraise "active_support_redis_cache_store_pooled" do +appraise "active_support_5_redis_cache_store_pooled" do gem "activesupport", "~> 5.2.0" gem "connection_pool", "~> 2.2" - gem "redis", "~> 4.0" + gem "redis", "~> 5.0" end appraise "redis_store" do gem "redis-store", "~> 1.5" end - -appraise "active_support_redis_store" do - gem "redis-activesupport", "~> 5.0" -end diff --git a/CHANGELOG.md b/CHANGELOG.md index f125d166..35125fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,97 @@ # Changelog -All notable changes to this project will be documented in this file. +This file will no longer be updated - all changes after v6.7.0 will only be documented in the relevant release note. + +## [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 @@ -63,9 +154,9 @@ All notable changes to this project will be documented in this file. ### Added -- Support "plain" `Redis` as a cache store backend ([#280](https://github.com/kickstarter/rack-attack/pull/280)). Thanks @bfad and @ryandv. +- 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/kickstarter/rack-attack/pull/282)). Thanks @doliveirakn. +so your custom code is less prone to race conditions ([#282](https://github.com/rack/rack-attack/pull/282)). Thanks @doliveirakn. ### Dependency changes @@ -87,43 +178,43 @@ so your custom code is less prone to race conditions ([#282](https://github.com/ ### Added -- Add support for [`ActiveSupport::Cache::RedisCacheStore`](http://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html) as a store backend ([#340](https://github.com/kickstarter/rack-attack/pull/340) and [#350](https://github.com/kickstarter/rack-attack/pull/350)) +- 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/kickstarter/rack-attack/pull/320)) -- Shorthand for blocking an IP subnet `Rack::Attack.blocklist_ip("1.2.0.0/16")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) -- Shorthand for safelisting an IP address `Rack::Attack.safelist_ip("5.6.7.8")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) -- Shorthand for safelisting an IP subnet `Rack::Attack.safelist_ip("5.6.0.0/16")` ([#320](https://github.com/kickstarter/rack-attack/pull/320)) -- Throw helpful error message when using `allow2ban` but cache store is misconfigured ([#315](https://github.com/kickstarter/rack-attack/issues/315)) -- Throw helpful error message when using `fail2ban` but cache store is misconfigured ([#315](https://github.com/kickstarter/rack-attack/issues/315)) +- 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/kickstarter/rack-attack/issues/253) ([#271](https://github.com/kickstarter/rack-attack/issues/271)) - - Throws errors with better semantics when missing or misconfigured store caches to aid in developers debugging their configs ([#274](https://github.com/kickstarter/rack-attack/issues/274)) - - Removed legacy code that was originally intended for Rails 3 apps ([#264](https://github.com/kickstarter/rack-attack/issues/264)) + - 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/kickstarter/rack-attack/issues/198)) + - 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/kickstarter/rack-attack/issues/181), + - 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. ## [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](https://github.com/kickstarter/rack-attack/issues/165)) + saying dalli was a required dependency. I learned all about ActiveSupport autoloading. ([#165](https://github.com/rack/rack-attack/issues/165)) ## [4.4.0] - 2016-02-10 - - New: support for MemCacheStore ([#153](https://github.com/kickstarter/rack-attack/issues/153)). Thanks @elhu. + - New: support for MemCacheStore ([#153](https://github.com/rack/rack-attack/issues/153)). Thanks @elhu. - Some documentation and test harness improvements. ## [4.3.1] - 2015-12-18 @@ -189,29 +280,44 @@ so your custom code is less prone to race conditions ([#282](https://github.com/ - Remove unused variable - Extract mandatory options to constants -[6.2.0]: https://github.com/kickstarter/rack-attack/compare/v6.1.0...v6.2.0/ -[6.1.0]: https://github.com/kickstarter/rack-attack/compare/v6.0.0...v6.1.0/ -[6.0.0]: https://github.com/kickstarter/rack-attack/compare/v5.4.2...v6.0.0/ -[5.4.2]: https://github.com/kickstarter/rack-attack/compare/v5.4.1...v5.4.2/ -[5.4.1]: https://github.com/kickstarter/rack-attack/compare/v5.4.0...v5.4.1/ -[5.4.0]: https://github.com/kickstarter/rack-attack/compare/v5.3.2...v5.4.0/ -[5.3.2]: https://github.com/kickstarter/rack-attack/compare/v5.3.1...v5.3.2/ -[5.3.1]: https://github.com/kickstarter/rack-attack/compare/v5.3.0...v5.3.1/ -[5.3.0]: https://github.com/kickstarter/rack-attack/compare/v5.2.0...v5.3.0/ -[5.2.0]: https://github.com/kickstarter/rack-attack/compare/v5.1.0...v5.2.0/ -[5.1.0]: https://github.com/kickstarter/rack-attack/compare/v5.0.1...v5.1.0/ -[5.0.1]: https://github.com/kickstarter/rack-attack/compare/v5.0.0...v5.0.1/ -[5.0.0]: https://github.com/kickstarter/rack-attack/compare/v4.4.1...v5.0.0/ -[4.4.1]: https://github.com/kickstarter/rack-attack/compare/v4.4.0...v4.4.1/ -[4.4.0]: https://github.com/kickstarter/rack-attack/compare/v4.3.1...v4.4.0/ -[4.3.1]: https://github.com/kickstarter/rack-attack/compare/v4.3.0...v4.3.1/ -[4.3.0]: https://github.com/kickstarter/rack-attack/compare/v4.2.0...v4.3.0/ -[4.2.0]: https://github.com/kickstarter/rack-attack/compare/v4.1.1...v4.2.0/ -[4.1.1]: https://github.com/kickstarter/rack-attack/compare/v4.1.0...v4.1.1/ -[4.1.0]: https://github.com/kickstarter/rack-attack/compare/v4.0.1...v4.1.0/ -[4.0.1]: https://github.com/kickstarter/rack-attack/compare/v4.0.0...v4.0.1/ -[4.0.0]: https://github.com/kickstarter/rack-attack/compare/v3.0.0...v4.0.0/ -[3.0.0]: https://github.com/kickstarter/rack-attack/compare/v2.3.0...v3.0.0/ -[2.3.0]: https://github.com/kickstarter/rack-attack/compare/v2.2.1...v2.3.0/ -[2.2.1]: https://github.com/kickstarter/rack-attack/compare/v2.2.0...v2.2.1/ -[2.2.0]: https://github.com/kickstarter/rack-attack/compare/v2.1.1...v2.2.0/ + +[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/ + +[@fatkodima]: https://github.com/fatkodima +[@rofreg]: https://github.com/rofreg +[@NikolayRys]: https://github.com/NikolayRys +[@ixti]: https://github.com/ixti diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6645611..042a12b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,12 +8,13 @@ This project is intended to be a safe, welcoming space for collaboration, and co Any of the following is greatly appreciated: -* Helping users by answering to their [questions](https://github.com/kickstarter/rack-attack/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+question%22) -* Helping users troubleshoot their [error reports](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+error+report%22) to figure out if the error is caused by an actual bug or some misconfiguration -* Giving feedback by commenting in other users [feature requests](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22type%3A+feature+request%22) -* Reporting an error you are experiencing -* Suggesting a new feature you think it would be useful for many users -* If you want to work on fixing an actual issue and you don't know where to start, those labeled [good first issue](https://github.com/kickstarter/rack-attack/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) may be a good choice +* 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 diff --git a/Gemfile b/Gemfile index 7f4f5e95..69753c94 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,8 @@ source 'https://rubygems.org' gemspec + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end diff --git a/README.md b/README.md index 866cf354..545003cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -__Note__: You are viewing the development version README. -For the README consistent with the latest released version see https://github.com/kickstarter/rack-attack/blob/6-stable/README.md. +: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. # Rack::Attack @@ -10,7 +10,7 @@ Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily 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 Status](https://travis-ci.org/kickstarter/rack-attack.svg?branch=master)](https://travis-ci.org/kickstarter/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) [![Code Climate](https://codeclimate.com/github/kickstarter/rack-attack.svg)](https://codeclimate.com/github/kickstarter/rack-attack) [![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) @@ -37,9 +37,9 @@ See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-ha - [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) -- [Testing](#testing) - [Performance](#performance) - [Motivation](#motivation) - [Contributing](#contributing) @@ -71,12 +71,7 @@ Or install it yourself as: Then tell your ruby web application to use rack-attack as a middleware. -a) For __rails__ applications with versions >= 5.1 it is used by default. For older rails versions you should enable it explicitly: -```ruby -# In config/application.rb - -config.middleware.use Rack::Attack -``` +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: @@ -140,7 +135,7 @@ E.g. # Provided that trusted users use an HTTP request header named APIKey Rack::Attack.safelist("mark any authenticated access safe") do |request| # Requests are allowed if the return value is truthy - request.env["APIKey"] == "secret-string" + request.env["HTTP_APIKEY"] == "secret-string" end # Always allow requests from localhost @@ -263,10 +258,12 @@ Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request| end # Throttle login attempts for a given email parameter to 6 reqs/minute -# Return the email as a discriminator on POST /login requests +# 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? - req.params['email'] + # 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 @@ -308,28 +305,33 @@ end Throttle, allow2ban and fail2ban state is stored in a configurable cache (which defaults to `Rails.cache` if present), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)). ```ruby -Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache +# 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: "...") ``` -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). +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. ## 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). +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'], - # env['rack.attack.match_discriminator'] + # 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 @@ -342,7 +344,7 @@ end 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. -If you want to return to user how many seconds to wait until he can start sending requests again, this can be done through enabling `Retry-After` header: +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 ``` @@ -350,8 +352,8 @@ 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| - 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 = { @@ -377,7 +379,7 @@ Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/ 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` namesapce. +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 @@ -398,6 +400,20 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, r end ``` +## Testing + +A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will +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. @@ -416,9 +432,9 @@ def call(env) if safelisted?(req) @app.call(env) elsif blocklisted?(req) - self.class.blocklisted_response.call(env) + self.class.blocklisted_responder.call(req) elsif throttled?(req) - self.class.throttled_response.call(env) + self.class.throttled_responder.call(req) else tracked?(req) @app.call(env) @@ -434,13 +450,6 @@ can cleanly monkey patch helper methods onto the `Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. - -## Testing - -A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will -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. - ## Performance The overhead of running Rack::Attack is typically negligible (a few milliseconds per request), diff --git a/docs/advanced_configuration.md b/docs/advanced_configuration.md index 0fd1fe4c..6d8737ea 100644 --- a/docs/advanced_configuration.md +++ b/docs/advanced_configuration.md @@ -6,7 +6,7 @@ If you're feeling ambitious or you have a very particular use-case for Rack::Att ### 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/kickstarter/rack-attack/issues/106) for more discussion. +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 @@ -24,7 +24,7 @@ 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/kickstarter/rack-attack/issues/73) for more discussion. +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 @@ -38,7 +38,7 @@ 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/kickstarter/rack-attack/issues/110) for more discussion. +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 @@ -57,7 +57,7 @@ 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/kickstarter/rack-attack/issues/113) for more discussion. +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" @@ -65,7 +65,7 @@ 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/kickstarter/rack-attack/issues/111) for more discussion. +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 @@ -78,7 +78,7 @@ end ### Throttle Basic Auth Crackers -An example implementation for blocking hackers who spam basic auth attempts. See [#47](https://github.com/kickstarter/rack-attack/issues/47) for more discussion. +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, diff --git a/docs/development.md b/docs/development.md index c3fec28f..b32cecf8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,6 +8,10 @@ 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 index 069f04e9..cfe77581 100644 --- a/docs/example_configuration.md +++ b/docs/example_configuration.md @@ -53,16 +53,17 @@ class Rack::Attack # Throttle POST requests to /login by email param # - # Key: "rack::attack:#{Time.now.to_i/:period}:logins/email:#{req.email}" + # 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| + throttle('logins/email', limit: 5, period: 20.seconds) do |req| if req.path == '/login' && req.post? - # return the email if present, nil otherwise - req.params['email'].presence + # 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 @@ -74,7 +75,7 @@ class Rack::Attack # 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_response = lambda do |env| + # self.throttled_responder = lambda do |env| # [ 503, # status # {}, # headers # ['']] # body diff --git a/examples/rack_attack.rb b/examples/rack_attack.rb index 43f1348a..7423f39a 100644 --- a/examples/rack_attack.rb +++ b/examples/rack_attack.rb @@ -13,8 +13,10 @@ end # Throttle login attempts per email, 10/minute/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'] + 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_redis_cache_store.gemfile b/gemfiles/active_support_5_redis_cache_store.gemfile similarity index 56% rename from gemfiles/active_support_redis_cache_store.gemfile rename to gemfiles/active_support_5_redis_cache_store.gemfile index 30e1e38b..0b800a0f 100644 --- a/gemfiles/active_support_redis_cache_store.gemfile +++ b/gemfiles/active_support_5_redis_cache_store.gemfile @@ -3,6 +3,11 @@ source "https://rubygems.org" gem "activesupport", "~> 5.2.0" -gem "redis", "~> 4.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end gemspec path: "../" diff --git a/gemfiles/active_support_redis_cache_store_pooled.gemfile b/gemfiles/active_support_5_redis_cache_store_pooled.gemfile similarity index 62% rename from gemfiles/active_support_redis_cache_store_pooled.gemfile rename to gemfiles/active_support_5_redis_cache_store_pooled.gemfile index 9232a9b5..9127da50 100644 --- a/gemfiles/active_support_redis_cache_store_pooled.gemfile +++ b/gemfiles/active_support_5_redis_cache_store_pooled.gemfile @@ -4,6 +4,11 @@ source "https://rubygems.org" gem "activesupport", "~> 5.2.0" gem "connection_pool", "~> 2.2" -gem "redis", "~> 4.0" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end gemspec path: "../" diff --git a/gemfiles/active_support_6_redis_cache_store.gemfile b/gemfiles/active_support_6_redis_cache_store.gemfile new file mode 100644 index 00000000..72fb5b1d --- /dev/null +++ b/gemfiles/active_support_6_redis_cache_store.gemfile @@ -0,0 +1,13 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 6.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_6_redis_cache_store_pooled.gemfile b/gemfiles/active_support_6_redis_cache_store_pooled.gemfile new file mode 100644 index 00000000..36a40f57 --- /dev/null +++ b/gemfiles/active_support_6_redis_cache_store_pooled.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 6.1.0" +gem "connection_pool", "~> 2.2" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" 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_0_redis_cache_store_pooled.gemfile b/gemfiles/active_support_7_0_redis_cache_store_pooled.gemfile new file mode 100644 index 00000000..bd2a6e71 --- /dev/null +++ b/gemfiles/active_support_7_0_redis_cache_store_pooled.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 7.0.0" +gem "connection_pool", "~> 2.2" +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_1_redis_cache_store_pooled.gemfile b/gemfiles/active_support_7_1_redis_cache_store_pooled.gemfile new file mode 100644 index 00000000..ae2d6d96 --- /dev/null +++ b/gemfiles/active_support_7_1_redis_cache_store_pooled.gemfile @@ -0,0 +1,14 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activesupport", "~> 7.1.0" +gem "connection_pool", "~> 2.2" +gem "redis", "~> 5.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + +gemspec path: "../" diff --git a/gemfiles/active_support_redis_store.gemfile b/gemfiles/active_support_redis_store.gemfile deleted file mode 100644 index 517c70f9..00000000 --- a/gemfiles/active_support_redis_store.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "redis-activesupport", "~> 5.0" - -gemspec path: "../" diff --git a/gemfiles/connection_pool_dalli.gemfile b/gemfiles/connection_pool_dalli.gemfile index 69dc8870..f84eb52e 100644 --- a/gemfiles/connection_pool_dalli.gemfile +++ b/gemfiles/connection_pool_dalli.gemfile @@ -3,6 +3,11 @@ source "https://rubygems.org" gem "connection_pool", "~> 2.2" -gem "dalli", "~> 2.7" +gem "dalli", "~> 3.0" + +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end gemspec path: "../" diff --git a/gemfiles/dalli2.gemfile b/gemfiles/dalli2.gemfile index c47d5afa..eb7e4acb 100644 --- a/gemfiles/dalli2.gemfile +++ b/gemfiles/dalli2.gemfile @@ -4,4 +4,9 @@ source "https://rubygems.org" gem "dalli", "~> 2.0" +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +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_1_6.gemfile b/gemfiles/rack_1.gemfile similarity index 65% rename from gemfiles/rack_1_6.gemfile rename to gemfiles/rack_1.gemfile index 0cc00506..36b2f91b 100644 --- a/gemfiles/rack_1_6.gemfile +++ b/gemfiles/rack_1.gemfile @@ -4,7 +4,12 @@ source "https://rubygems.org" gem "actionpack", ">= 4.2" gem "activesupport", ">= 4.2" -gem "rack", "~> 1.6.9" +gem "rack", "~> 1.6" gem "rack-test", ">= 0.6" +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_2_0.gemfile b/gemfiles/rack_2_0.gemfile deleted file mode 100644 index 9915a10d..00000000 --- a/gemfiles/rack_2_0.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rack", "~> 2.0.4" - -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_4_2.gemfile b/gemfiles/rails_4_2.gemfile deleted file mode 100644 index 055cf9f6..00000000 --- a/gemfiles/rails_4_2.gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "railties", "~> 4.2.0" -gem "rack-test", ">= 0.6" - -gemspec path: "../" diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile deleted file mode 100644 index 66a5a0b9..00000000 --- a/gemfiles/rails_5_1.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "railties", "~> 5.1.0" - -gemspec path: "../" diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile index 8b2627fc..161bb698 100644 --- a/gemfiles/rails_5_2.gemfile +++ b/gemfiles/rails_5_2.gemfile @@ -4,4 +4,9 @@ source "https://rubygems.org" gem "railties", "~> 5.2.0" +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + gemspec path: "../" diff --git a/gemfiles/rails_6_0.gemfile b/gemfiles/rails_6_0.gemfile index 4cd55a81..679fea78 100644 --- a/gemfiles/rails_6_0.gemfile +++ b/gemfiles/rails_6_0.gemfile @@ -4,4 +4,9 @@ source "https://rubygems.org" gem "railties", "~> 6.0.0" +group :maintenance, optional: true do + gem "bake" + gem "bake-gem" +end + gemspec path: "../" diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile new file mode 100644 index 00000000..b1e5c039 --- /dev/null +++ b/gemfiles/rails_6_1.gemfile @@ -0,0 +1,12 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "railties", "~> 6.1.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/redis_3.gemfile b/gemfiles/redis_3.gemfile deleted file mode 100644 index 403482c1..00000000 --- a/gemfiles/redis_3.gemfile +++ /dev/null @@ -1,7 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "redis", "~> 3.3" - -gemspec path: "../" diff --git a/gemfiles/redis_4.gemfile b/gemfiles/redis_4.gemfile index 701e936c..e8b82f16 100644 --- a/gemfiles/redis_4.gemfile +++ b/gemfiles/redis_4.gemfile @@ -4,4 +4,9 @@ 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 index 8aafc6d1..e32d1e9e 100644 --- a/gemfiles/redis_store.gemfile +++ b/gemfiles/redis_store.gemfile @@ -4,4 +4,9 @@ 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 64771fc9..5c04821a 100644 --- a/lib/rack/attack.rb +++ b/lib/rack/attack.rb @@ -6,33 +6,34 @@ require 'rack/attack/configuration' require 'rack/attack/path_normalizer' require 'rack/attack/request' -require "ipaddr" +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 :StoreProxy, 'rack/attack/store_proxy' - autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy' - autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy' - autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy' - autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy' - autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy' - autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy' autoload :Fail2Ban, 'rack/attack/fail2ban' autoload :Allow2Ban, 'rack/attack/allow2ban' class << self - attr_accessor :enabled, :notifier + attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer attr_reader :configuration def instrument(request) @@ -54,6 +55,10 @@ def clear! @configuration.clear_configuration end + def reset! + cache.reset! + end + extend Forwardable def_delegators( :@configuration, @@ -63,6 +68,10 @@ def clear! :safelist_ip, :throttle, :track, + :throttled_responder, + :throttled_responder=, + :blocklisted_responder, + :blocklisted_responder=, :blocklisted_response, :blocklisted_response=, :throttled_response, @@ -80,6 +89,9 @@ def clear! # 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 @@ -96,17 +108,28 @@ def initialize(app, &block) end def call(env) - return @app.call(env) unless self.class.enabled + 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) - configuration.blocklisted_response.call(env) + # 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) - configuration.throttled_response.call(env) + # 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) 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/cache.rb b/lib/rack/attack/cache.rb index cfa2efa4..ecbd3368 100644 --- a/lib/rack/attack/cache.rb +++ b/lib/rack/attack/cache.rb @@ -6,14 +6,26 @@ class Cache attr_accessor :prefix attr_reader :last_epoch_time - def initialize - self.store = ::Rails.cache if defined?(::Rails.cache) + def self.default_store + if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) + ::Rails.cache + end + end + + 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) @@ -41,11 +53,22 @@ 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) @last_epoch_time = Time.now.to_i - # Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA + # 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 diff --git a/lib/rack/attack/check.rb b/lib/rack/attack/check.rb index 4c985ebe..c9f3ff7d 100644 --- a/lib/rack/attack/check.rb +++ b/lib/rack/attack/check.rb @@ -4,6 +4,7 @@ module Rack class Attack class Check attr_reader :name, :block, :type + def initialize(name, options = {}, &block) @name = name @block = block diff --git a/lib/rack/attack/configuration.rb b/lib/rack/attack/configuration.rb index 5a15c745..a4bdc987 100644 --- a/lib/rack/attack/configuration.rb +++ b/lib/rack/attack/configuration.rb @@ -1,33 +1,45 @@ # frozen_string_literal: true +require "ipaddr" + module Rack class Attack class Configuration - attr_reader :safelists, :blocklists, :throttles, :anonymous_blocklists, :anonymous_safelists - attr_accessor :blocklisted_response, :throttled_response, :throttled_response_retry_after_header + DEFAULT_BLOCKLISTED_RESPONDER = lambda { |_req| [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] } - def initialize - @safelists = {} - @blocklists = {} - @throttles = {} - @tracks = {} - @anonymous_blocklists = [] - @anonymous_safelists = [] - @throttled_response_retry_after_header = false + 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]) - @blocklisted_response = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] } - @throttled_response = lambda do |env| - if throttled_response_retry_after_header - match_data = 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 + [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, :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) @@ -49,11 +61,15 @@ def blocklist(name = nil, &block) end def blocklist_ip(ip_address) - @anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } + @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 { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } + @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) @@ -87,6 +103,12 @@ def tracked?(request) end def clear_configuration + set_defaults + end + + private + + def set_defaults @safelists = {} @blocklists = {} @throttles = {} @@ -94,6 +116,13 @@ def clear_configuration @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 diff --git a/lib/rack/attack/path_normalizer.rb b/lib/rack/attack/path_normalizer.rb index 110ff6fa..deafa888 100644 --- a/lib/rack/attack/path_normalizer.rb +++ b/lib/rack/attack/path_normalizer.rb @@ -4,7 +4,9 @@ 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://git.io/v0rrR for implementation.) + # (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 diff --git a/lib/rack/attack/railtie.rb b/lib/rack/attack/railtie.rb index ceed9f7d..9521493b 100644 --- a/lib/rack/attack/railtie.rb +++ b/lib/rack/attack/railtie.rb @@ -1,20 +1,16 @@ # frozen_string_literal: true +begin + require 'rails/railtie' +rescue LoadError + return +end + module Rack class Attack class Railtie < ::Rails::Railtie - initializer 'rack.attack.middleware', after: :load_config_initializers, before: :build_middleware_stack do |app| - if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1") - middlewares = app.config.middleware - operations = middlewares.send(:operations) + middlewares.send(:delete_operations) - - use_middleware = operations.none? do |operation| - middleware = operation[1] - middleware.include?(Rack::Attack) - end - - middlewares.use(Rack::Attack) if use_middleware - end + initializer "rack-attack.middleware" do |app| + app.middleware.use(Rack::Attack) end end end diff --git a/lib/rack/attack/store_proxy.rb b/lib/rack/attack/store_proxy.rb deleted file mode 100644 index 55c63e21..00000000 --- a/lib/rack/attack/store_proxy.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Rack - class Attack - module StoreProxy - PROXIES = [ - DalliProxy, - MemCacheStoreProxy, - RedisStoreProxy, - RedisProxy, - RedisCacheStoreProxy, - ActiveSupportRedisStoreProxy - ].freeze - - def self.build(store) - klass = PROXIES.find { |proxy| proxy.handle?(store) } - klass ? klass.new(store) : store - end - end - end -end diff --git a/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb b/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb deleted file mode 100644 index 68f0326f..00000000 --- a/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'delegate' - -module Rack - class Attack - module StoreProxy - class ActiveSupportRedisStoreProxy < SimpleDelegator - def self.handle?(store) - defined?(::Redis) && - defined?(::ActiveSupport::Cache::RedisStore) && - store.is_a?(::ActiveSupport::Cache::RedisStore) - end - - def increment(name, amount = 1, options = {}) - # #increment ignores options[:expires_in]. - # - # So in order to workaround this we use #write (which sets expiration) to initialize - # the counter. After that we continue using the original #increment. - if options[:expires_in] && !read(name) - write(name, amount, options) - - amount - else - super - end - end - - def read(name, options = {}) - super(name, options.merge!(raw: true)) - end - - def write(name, value, options = {}) - super(name, value, options.merge!(raw: true)) - end - end - end - end -end diff --git a/lib/rack/attack/store_proxy/dalli_proxy.rb b/lib/rack/attack/store_proxy/dalli_proxy.rb index 360e2198..48198bb2 100644 --- a/lib/rack/attack/store_proxy/dalli_proxy.rb +++ b/lib/rack/attack/store_proxy/dalli_proxy.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require 'delegate' +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) diff --git a/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb b/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb index f8c036c9..f7b66c92 100644 --- a/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb +++ b/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb @@ -1,17 +1,21 @@ # frozen_string_literal: true -require 'delegate' +require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy - class MemCacheStoreProxy < SimpleDelegator + 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 diff --git a/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb index f4081bee..00670f06 100644 --- a/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb @@ -1,47 +1,35 @@ # frozen_string_literal: true -require 'delegate' +require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy - class RedisCacheStoreProxy < SimpleDelegator + class RedisCacheStoreProxy < BaseProxy def self.handle?(store) store.class.name == "ActiveSupport::Cache::RedisCacheStore" end - def increment(name, amount = 1, options = {}) + def increment(name, amount = 1, **options) # RedisCacheStore#increment ignores options[:expires_in]. # # So in order to workaround this we use RedisCacheStore#write (which sets expiration) to initialize # the counter. After that we continue using the original RedisCacheStore#increment. - rescuing do - if options[:expires_in] && !read(name) - write(name, amount, options) + if options[:expires_in] && !read(name) + write(name, amount, options) - amount - else - super - end + amount + else + super end end - def read(*_args) - rescuing { super } + def read(name, options = {}) + super(name, options.merge!(raw: true)) end def write(name, value, options = {}) - rescuing do - super(name, value, options.merge!(raw: true)) - end - end - - private - - def rescuing - yield - rescue Redis::BaseError - nil + super(name, value, options.merge!(raw: true)) end end end diff --git a/lib/rack/attack/store_proxy/redis_proxy.rb b/lib/rack/attack/store_proxy/redis_proxy.rb index 1bcb4b48..830d39de 100644 --- a/lib/rack/attack/store_proxy/redis_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_proxy.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -require 'delegate' +require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy - class RedisProxy < SimpleDelegator + class RedisProxy < BaseProxy def initialize(*args) if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3") warn 'RackAttack requires Redis gem >= 3.0.0.' @@ -15,7 +15,7 @@ def initialize(*args) end def self.handle?(store) - defined?(::Redis) && store.is_a?(::Redis) + defined?(::Redis) && store.class == ::Redis end def read(key) @@ -32,9 +32,9 @@ def write(key, value, options = {}) def increment(key, amount, options = {}) rescuing do - pipelined do - incrby(key, amount) - expire(key, options[:expires_in]) if options[:expires_in] + pipelined do |redis| + redis.incrby(key, amount) + redis.expire(key, options[:expires_in]) if options[:expires_in] end.first end end @@ -43,11 +43,24 @@ def delete(key, _options = {}) rescuing { del(key) } end + def delete_matched(matcher, _options = nil) + cursor = "0" + + rescuing do + # Fetch keys in batches using SCAN to avoid blocking the Redis server. + loop do + cursor, keys = scan(cursor, match: matcher, count: 1000) + del(*keys) unless keys.empty? + break if cursor == "0" + end + end + end + private def rescuing yield - rescue Redis::BaseError + rescue Redis::BaseConnectionError nil 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 6be54128..28557bcb 100644 --- a/lib/rack/attack/store_proxy/redis_store_proxy.rb +++ b/lib/rack/attack/store_proxy/redis_store_proxy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'delegate' +require 'rack/attack/store_proxy/redis_proxy' module Rack class Attack diff --git a/lib/rack/attack/throttle.rb b/lib/rack/attack/throttle.rb index 3b80d9e7..0ec5f7aa 100644 --- a/lib/rack/attack/throttle.rb +++ b/lib/rack/attack/throttle.rb @@ -6,6 +6,7 @@ class Throttle MANDATORY_OPTIONS = [:limit, :period].freeze attr_reader :name, :limit, :period, :block, :type + def initialize(name, options, &block) @name = name @block = block @@ -22,8 +23,7 @@ def cache end def matched_by?(request) - discriminator = block.call(request) - + discriminator = discriminator_for(request) return false unless discriminator current_period = period_for(request) @@ -38,8 +38,9 @@ def matched_by?(request) epoch_time: cache.last_epoch_time } + annotate_request_with_throttle_data(request, data) + (count > current_limit).tap do |throttled| - annotate_request_with_throttle_data(request, data) if throttled annotate_request_with_matched_data(request, data) Rack::Attack.instrument(request) @@ -49,6 +50,14 @@ def matched_by?(request) 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 diff --git a/lib/rack/attack/version.rb b/lib/rack/attack/version.rb index 715c7836..754fe57b 100644 --- a/lib/rack/attack/version.rb +++ b/lib/rack/attack/version.rb @@ -2,6 +2,6 @@ module Rack class Attack - VERSION = '6.2.0' + VERSION = '6.7.0' end end diff --git a/rack-attack.gemspec b/rack-attack.gemspec index 0bebfa36..41cc7a8f 100644 --- a/rack-attack.gemspec +++ b/rack-attack.gemspec @@ -1,9 +1,6 @@ # frozen_string_literal: true -lib = File.expand_path('lib', __dir__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) - -require 'rack/attack/version' +require_relative 'lib/rack/attack/version' Gem::Specification.new do |s| s.name = 'rack-attack' @@ -14,31 +11,33 @@ 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 = 'https://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 = 'Block & throttle abusive requests' s.test_files = Dir.glob("spec/**/*") s.metadata = { - "bug_tracker_uri" => "https://github.com/kickstarter/rack-attack/issues", - "changelog_uri" => "https://github.com/kickstarter/rack-attack/blob/master/CHANGELOG.md", - "source_code_uri" => "https://github.com/kickstarter/rack-attack" + "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.3' + s.required_ruby_version = '>= 2.4' - s.add_runtime_dependency 'rack', ">= 1.0", "< 3" + 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', "~> 1.0" + s.add_development_dependency 'rack-test', "~> 2.0" s.add_development_dependency 'rake', "~> 13.0" - s.add_development_dependency "rubocop", "0.75.0" - s.add_development_dependency "rubocop-performance", "~> 1.5.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 @@ -46,5 +45,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'byebug', '~> 11.0' end - s.add_development_dependency 'railties', '>= 4.2', '< 6.1' + s.add_development_dependency "activesupport" end diff --git a/spec/acceptance/blocking_ip_spec.rb b/spec/acceptance/blocking_ip_spec.rb index 102a8fce..4d08042f 100644 --- a/spec/acceptance/blocking_ip_spec.rb +++ b/spec/acceptance/blocking_ip_spec.rb @@ -19,6 +19,12 @@ 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 notified = false notification_type = nil diff --git a/spec/acceptance/cache_store_config_for_fail2ban_spec.rb b/spec/acceptance/cache_store_config_for_fail2ban_spec.rb index 6f330eee..6fd79807 100644 --- a/spec/acceptance/cache_store_config_for_fail2ban_spec.rb +++ b/spec/acceptance/cache_store_config_for_fail2ban_spec.rb @@ -79,7 +79,7 @@ def write(key, value); end end it "works with any object that responds to #read, #write and #increment" do - FakeStore = Class.new do + fake_store_class = Class.new do attr_accessor :backend def initialize @@ -100,7 +100,7 @@ def increment(key, _count, _options = {}) end end - Rack::Attack.cache.store = FakeStore.new + Rack::Attack.cache.store = fake_store_class.new get "/" assert_equal 200, last_response.status diff --git a/spec/acceptance/customizing_blocked_response_spec.rb b/spec/acceptance/customizing_blocked_response_spec.rb index fd830c26..1ca127cc 100644 --- a/spec/acceptance/customizing_blocked_response_spec.rb +++ b/spec/acceptance/customizing_blocked_response_spec.rb @@ -14,7 +14,7 @@ assert_equal 403, last_response.status - Rack::Attack.blocklisted_response = lambda do |_env| + Rack::Attack.blocklisted_responder = lambda do |_req| [503, {}, ["Blocked"]] end @@ -28,9 +28,9 @@ matched = nil match_type = nil - Rack::Attack.blocklisted_response = lambda do |env| - matched = env['rack.attack.matched'] - match_type = env['rack.attack.match_type'] + Rack::Attack.blocklisted_responder = lambda do |req| + matched = req.env['rack.attack.matched'] + match_type = req.env['rack.attack.match_type'] [503, {}, ["Blocked"]] end @@ -40,4 +40,21 @@ 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 index 5c849790..0990975e 100644 --- a/spec/acceptance/customizing_throttled_response_spec.rb +++ b/spec/acceptance/customizing_throttled_response_spec.rb @@ -20,7 +20,7 @@ assert_equal 429, last_response.status - Rack::Attack.throttled_response = lambda do |_env| + Rack::Attack.throttled_responder = lambda do |_req| [503, {}, ["Throttled"]] end @@ -36,11 +36,11 @@ match_data = nil match_discriminator = nil - Rack::Attack.throttled_response = lambda do |env| - matched = env['rack.attack.matched'] - match_type = env['rack.attack.match_type'] - match_data = env['rack.attack.match_data'] - match_discriminator = env['rack.attack.match_discriminator'] + 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 @@ -58,4 +58,25 @@ 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 index a4ea1a62..5449b90c 100644 --- a/spec/acceptance/extending_request_object_spec.rb +++ b/spec/acceptance/extending_request_object_spec.rb @@ -4,10 +4,8 @@ describe "Extending the request object" do before do - class Rack::Attack::Request - def authorized? - env["APIKey"] == "private-secret" - end + Rack::Attack::Request.define_method :authorized? do + env["APIKey"] == "private-secret" end Rack::Attack.blocklist("unauthorized requests") do |request| @@ -17,9 +15,7 @@ def authorized? # We don't want the extension to leak to other test cases after do - class Rack::Attack::Request - remove_method :authorized? - end + Rack::Attack::Request.undef_method :authorized? end it "forbids request if blocklist condition is true" do diff --git a/spec/acceptance/rails_middleware_spec.rb b/spec/acceptance/rails_middleware_spec.rb index 2c69b7e1..31dc6209 100644 --- a/spec/acceptance/rails_middleware_spec.rb +++ b/spec/acceptance/rails_middleware_spec.rb @@ -2,7 +2,7 @@ require_relative "../spec_helper" -if defined?(Rails) +if defined?(Rails::Application) describe "Middleware for Rails" do before do @app = Class.new(Rails::Application) do @@ -12,30 +12,9 @@ end end - if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new("5.1") - it "is used by default" do - @app.initialize! - assert_equal 1, @app.middleware.count(Rack::Attack) - end - - it "is not added when it was added explicitly" do - @app.config.middleware.use(Rack::Attack) - @app.initialize! - assert_equal 1, @app.middleware.count(Rack::Attack) - end - - it "is not added when it was explicitly deleted" do - @app.config.middleware.delete(Rack::Attack) - @app.initialize! - refute @app.middleware.include?(Rack::Attack) - end - end - - if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("5.1") - it "is not used by default" do - @app.initialize! - assert_equal 0, @app.middleware.count(Rack::Attack) - 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 index c0b8faf5..fd80dc1b 100644 --- a/spec/acceptance/safelisting_ip_spec.rb +++ b/spec/acceptance/safelisting_ip_spec.rb @@ -17,6 +17,12 @@ 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" diff --git a/spec/acceptance/stores/active_support_dalli_store_spec.rb b/spec/acceptance/stores/active_support_dalli_store_spec.rb index 58964434..70355161 100644 --- a/spec/acceptance/stores/active_support_dalli_store_spec.rb +++ b/spec/acceptance/stores/active_support_dalli_store_spec.rb @@ -2,7 +2,11 @@ require_relative "../../spec_helper" -if defined?(::Dalli) +should_run = + defined?(::Dalli) && + Gem::Version.new(::Dalli::VERSION) < Gem::Version.new("3") + +if should_run require_relative "../../support/cache_store_helper" require "active_support/cache/dalli_store" require "timecop" diff --git a/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb b/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb index 9c26e8d6..fe951074 100644 --- a/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb +++ b/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb @@ -21,6 +21,6 @@ Rack::Attack.cache.store.clear end - it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) + 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_redis_cache_store_spec.rb b/spec/acceptance/stores/active_support_redis_cache_store_spec.rb index f595ec2a..a824edea 100644 --- a/spec/acceptance/stores/active_support_redis_cache_store_spec.rb +++ b/spec/acceptance/stores/active_support_redis_cache_store_spec.rb @@ -20,6 +20,6 @@ Rack::Attack.cache.store.clear end - it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) + 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_redis_store_spec.rb b/spec/acceptance/stores/active_support_redis_store_spec.rb deleted file mode 100644 index 75e4d68d..00000000 --- a/spec/acceptance/stores/active_support_redis_store_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require_relative "../../spec_helper" - -if defined?(::ActiveSupport::Cache::RedisStore) - require_relative "../../support/cache_store_helper" - require "timecop" - - describe "ActiveSupport::Cache::RedisStore as a cache backend" do - before do - Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new - end - - after do - Rack::Attack.cache.store.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/redis_store_spec.rb b/spec/acceptance/stores/redis_store_spec.rb index d7e8e115..dee35bcf 100644 --- a/spec/acceptance/stores/redis_store_spec.rb +++ b/spec/acceptance/stores/redis_store_spec.rb @@ -6,7 +6,7 @@ if defined?(::Redis::Store) require "timecop" - describe "ActiveSupport::Cache::RedisStore as a cache backend" do + describe "Redis::Store as a cache backend" do before do Rack::Attack.cache.store = ::Redis::Store.new end diff --git a/spec/integration/offline_spec.rb b/spec/integration/offline_spec.rb index f9bacab8..85429a42 100644 --- a/spec/integration/offline_spec.rb +++ b/spec/integration/offline_spec.rb @@ -13,18 +13,22 @@ 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?(::ActiveSupport::Cache::RedisStore) +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::RedisStore.new(host: '127.0.0.1', port: 3333) + @cache.store = ActiveSupport::Cache::RedisCacheStore.new(host: '127.0.0.1', port: 3333) end end end @@ -46,6 +50,23 @@ end end +if defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) + describe 'when Memcached is offline' do + include OfflineExamples + + before do + Dalli.logger.level = Logger::FATAL + + @cache = Rack::Attack::Cache.new + @cache.store = ActiveSupport::Cache::MemCacheStore.new('127.0.0.1:22122') + end + + after do + Dalli.logger.level = Logger::INFO + end + end +end + if defined?(Redis) describe 'when Redis is offline' do include OfflineExamples diff --git a/spec/rack_attack_instrumentation_spec.rb b/spec/rack_attack_instrumentation_spec.rb index a8b4527f..d2291f77 100644 --- a/spec/rack_attack_instrumentation_spec.rb +++ b/spec/rack_attack_instrumentation_spec.rb @@ -1,42 +1,39 @@ # frozen_string_literal: true require_relative "spec_helper" +require 'active_support' +require 'active_support/subscriber' -# ActiveSupport::Subscribers added in ~> 4.0.2.0 -if ActiveSupport::VERSION::MAJOR > 3 - require_relative 'spec_helper' - require 'active_support/subscriber' - class CustomSubscriber < ActiveSupport::Subscriber - @notification_count = 0 +class CustomSubscriber < ActiveSupport::Subscriber + @notification_count = 0 - class << self - attr_accessor :notification_count - end + class << self + attr_accessor :notification_count + end - def throttle(_event) - self.class.notification_count += 1 - 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 '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 + 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 + 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_request_spec.rb b/spec/rack_attack_request_spec.rb index 8d4d27fc..8f27301a 100644 --- a/spec/rack_attack_request_spec.rb +++ b/spec/rack_attack_request_spec.rb @@ -5,10 +5,8 @@ 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| diff --git a/spec/rack_attack_spec.rb b/spec/rack_attack_spec.rb index 647a08d7..40936017 100644 --- a/spec/rack_attack_spec.rb +++ b/spec/rack_attack_spec.rb @@ -11,6 +11,10 @@ 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 end @@ -64,15 +68,15 @@ end end - describe '#blocklisted_response' do + describe '#blocklisted_responder' do it 'should exist' do - _(Rack::Attack.blocklisted_response).must_respond_to :call + _(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 @@ -99,4 +103,26 @@ end end end + + describe '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 + end end diff --git a/spec/rack_attack_throttle_spec.rb b/spec/rack_attack_throttle_spec.rb index dcb1e414..0b0d68ac 100644 --- a/spec/rack_attack_throttle_spec.rb +++ b/spec/rack_attack_throttle_spec.rb @@ -144,3 +144,47 @@ 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 + + it 'should not differentiate requests when throttle_discriminator_normalizer is enabled' 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 + + it 'should differentiate requests when throttle_discriminator_normalizer is disabled' do + begin + prev = Rack::Attack.throttle_discriminator_normalizer + Rack::Attack.throttle_discriminator_normalizer = nil + + 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 + 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 0db66e47..d8c53b8a 100644 --- a/spec/rack_attack_track_spec.rb +++ b/spec/rack_attack_track_spec.rb @@ -3,17 +3,19 @@ require_relative 'spec_helper' describe 'Rack::Attack.track' do - class Counter - def self.incr - @counter += 1 - end + let(:counter_class) do + Class.new do + def self.incr + @counter += 1 + end - def self.reset - @counter = 0 - end + def self.reset + @counter = 0 + end - def self.check - @counter + def self.check + @counter + end end end @@ -32,19 +34,19 @@ def self.check describe "with a notification subscriber and two tracks" do before do - Counter.reset + counter_class.reset # A second track Rack::Attack.track("homepage") { |req| req.path == "/" } ActiveSupport::Notifications.subscribe("track.rack_attack") do |*_args| - Counter.incr + counter_class.incr end get "/" end it "should notify twice" do - _(Counter.check).must_equal 2 + _(counter_class.check).must_equal 2 end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a1728cb6..f529e6a1 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,8 +5,7 @@ require "minitest/autorun" require "minitest/pride" require "rack/test" -require "rails" - +require "active_support" require "rack/attack" if RUBY_ENGINE == "ruby" @@ -22,30 +21,28 @@ def safe_require(name) safe_require "connection_pool" safe_require "dalli" safe_require "redis" -safe_require "redis-activesupport" safe_require "redis-store" -class MiniTest::Spec +class Minitest::Spec include Rack::Test::Methods before do - Rails.cache = nil - @_original_throttled_response = Rack::Attack.throttled_response - @_original_blocklisted_response = Rack::Attack.blocklisted_response + if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) + Rails.cache.clear + end end after do Rack::Attack.clear_configuration Rack::Attack.instance_variable_set(:@cache, nil) - - Rack::Attack.throttled_response = @_original_throttled_response - Rack::Attack.blocklisted_response = @_original_blocklisted_response end def app Rack::Builder.new do # Use Rack::Lint to test that rack-attack is complying with the rack spec use Rack::Lint + # Intentionally added twice to test idempotence property + use Rack::Attack use Rack::Attack use Rack::Lint