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