mirror of
https://github.com/samsonjs/rack-attack.git
synced 2026-03-25 09:25:49 +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
|
||||
.ruby-version
|
||||
.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
|
||||
cache: bundler
|
||||
|
||||
rvm:
|
||||
- 2.5.0
|
||||
- 2.4.3
|
||||
- 2.3.6
|
||||
- 2.2.9
|
||||
- jruby-9.1.14.0
|
||||
- 2.6.0-preview2
|
||||
- 2.5.1
|
||||
- 2.4.4
|
||||
- 2.3.7
|
||||
- jruby-9.1.16.0
|
||||
|
||||
before_install:
|
||||
- gem update --system
|
||||
# For jruby we need to stick with rubygems 2.7.4 until
|
||||
# https://github.com/rubygems/rubygems/issues/2188
|
||||
# is fixed and released.
|
||||
#
|
||||
# Without this workaround, for jruby builds, rubygems
|
||||
# activates jruby stdlib minitest (v5.4.1) instead of the
|
||||
# bundled version (v5.11.3).
|
||||
- if [ "${TRAVIS_RUBY_VERSION:0:5}" = "jruby" ]; then gem update --system 2.7.4; else gem update --system; fi
|
||||
- gem install bundler
|
||||
|
||||
gemfile:
|
||||
- gemfiles/rack_2_0.gemfile
|
||||
- gemfiles/rack_1_6.gemfile
|
||||
- gemfiles/rails_5_2.gemfile
|
||||
- gemfiles/rails_5_1.gemfile
|
||||
- gemfiles/rails_5_0.gemfile
|
||||
- gemfiles/rails_4_2.gemfile
|
||||
- gemfiles/dalli2.gemfile
|
||||
- gemfiles/connection_pool_dalli.gemfile
|
||||
- gemfiles/active_support_redis_cache_store.gemfile
|
||||
- gemfiles/active_support_redis_cache_store_pooled.gemfile
|
||||
- gemfiles/redis_store.gemfile
|
||||
- gemfiles/active_support_redis_store.gemfile
|
||||
|
||||
matrix:
|
||||
allow_failures:
|
||||
- rvm: 2.6.0-preview2
|
||||
|
||||
fast_finish: true
|
||||
|
||||
services:
|
||||
- redis
|
||||
|
|
|
|||
61
Appraisals
61
Appraisals
|
|
@ -1,18 +1,65 @@
|
|||
appraise 'rails_5-1' do
|
||||
gem 'activesupport', '~> 5.1.0'
|
||||
gem 'actionpack', '~> 5.1.0'
|
||||
# frozen_string_literal: true
|
||||
|
||||
appraise "rack_2_0" do
|
||||
gem "rack", "~> 2.0.4"
|
||||
end
|
||||
|
||||
appraise 'rails_5-0' do
|
||||
gem 'activesupport', '~> 5.0.0'
|
||||
gem 'actionpack', '~> 5.0.0'
|
||||
appraise "rack_1_6" do
|
||||
# Override activesupport and actionpack version constraints by making
|
||||
# it more loose so it's compatible with rack 1.6.x
|
||||
gem "actionpack", ">= 4.2"
|
||||
gem "activesupport", ">= 4.2"
|
||||
|
||||
gem "rack", "~> 1.6.9"
|
||||
|
||||
# Override rack-test version constraint by making it more loose
|
||||
# so it's compatible with actionpack 4.2.x
|
||||
gem "rack-test", ">= 0.6"
|
||||
end
|
||||
|
||||
appraise 'rails_5-2' do
|
||||
gem 'actionpack', '~> 5.2.0'
|
||||
gem 'activesupport', '~> 5.2.0'
|
||||
end
|
||||
|
||||
appraise 'rails_5-1' do
|
||||
gem 'actionpack', '~> 5.1.0'
|
||||
gem 'activesupport', '~> 5.1.0'
|
||||
end
|
||||
|
||||
appraise 'rails_4-2' do
|
||||
gem 'activesupport', '~> 4.2.0'
|
||||
gem 'actionpack', '~> 4.2.0'
|
||||
gem 'activesupport', '~> 4.2.0'
|
||||
|
||||
# Override rack-test version constraint by making it more loose
|
||||
# so it's compatible with actionpack 4.2.x
|
||||
gem "rack-test", ">= 0.6"
|
||||
end
|
||||
|
||||
appraise 'dalli2' do
|
||||
gem 'dalli', '~> 2.0'
|
||||
end
|
||||
|
||||
appraise "connection_pool_dalli" do
|
||||
gem "connection_pool", "~> 2.2"
|
||||
gem "dalli", "~> 2.7"
|
||||
end
|
||||
|
||||
appraise "active_support_redis_cache_store" do
|
||||
gem "activesupport", "~> 5.2.0"
|
||||
gem "redis", "~> 4.0"
|
||||
end
|
||||
|
||||
appraise "active_support_redis_cache_store_pooled" do
|
||||
gem "activesupport", "~> 5.2.0"
|
||||
gem "connection_pool", "~> 2.2"
|
||||
gem "redis", "~> 4.0"
|
||||
end
|
||||
|
||||
appraise "redis_store" do
|
||||
gem "redis-store", "~> 1.5"
|
||||
end
|
||||
|
||||
appraise "active_support_redis_store" do
|
||||
gem "redis-activesupport", "~> 5.0"
|
||||
end
|
||||
|
|
|
|||
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`.
|
||||
- Remove test coverage for unsupported ruby dependencies: ruby 2.0, activesupport 3.2/4.0, and dalli 1.
|
||||
|
||||
## v4.4.1 17 Feb 2016
|
||||
## [4.4.1] - 2016-02-17
|
||||
|
||||
- Fix a bug affecting apps using Redis::Store and ActiveSupport that could generate an error
|
||||
saying dalli was a required dependency. I learned all about ActiveSupport autoloading. (#165)
|
||||
saying dalli was a required dependency. I learned all about ActiveSupport autoloading. ([#165](https://github.com/kickstarter/rack-attack/issues/165))
|
||||
|
||||
## v4.4.0 - 10 Feb 2016
|
||||
## [4.4.0] - 2016-02-10
|
||||
|
||||
- New: support for MemCacheStore (#153). Thanks @elhu.
|
||||
- New: support for MemCacheStore ([#153](https://github.com/kickstarter/rack-attack/issues/153)). Thanks @elhu.
|
||||
- Some documentation and test harness improvements.
|
||||
|
||||
## v4.3.1 - 18 Dec 2015
|
||||
## [4.3.1] - 2015-12-18
|
||||
- SECURITY FIX: Normalize request paths when using ActionDispatch. Thanks
|
||||
Andres Riancho at @includesecurity for reporting it.
|
||||
- Remove support for ruby 1.9.x
|
||||
- Add Code of Conduct
|
||||
- Several documentation and testing improvements
|
||||
|
||||
## v4.3.0 - 22 May 2015
|
||||
## [4.3.0] - 2015-05-22
|
||||
|
||||
- Redis proxy passes `raw: true` (thanks @stanhu)
|
||||
- Redis supports `delete` method to be consistent with Dalli (thanks @stanhu)
|
||||
- Support the ability to reset Fail2Ban count and ban flag (thanks @stanhu)
|
||||
|
||||
## v4.2.0 - 26 Oct 2014
|
||||
## [4.2.0] - 2014-10-26
|
||||
- Throttle's `period` argument now takes a proc as well as a number (thanks @gsamokovarov)
|
||||
- Invoke the `#call` method on `blocklist_response` and `throttle_response` instead of `#[]`, as per the Rack spec. (thanks @gsamokovarov)
|
||||
|
||||
## v4.1.1 - 11 Sept 2014
|
||||
## [4.1.1] - 2014-09-11
|
||||
- Fix a race condition in throttles that could allow more requests than intended.
|
||||
|
||||
## v4.1.0 - 22 May 2014
|
||||
## [4.1.0] - 2014-05-22
|
||||
- Tracks take an optional limit and period to only notify once a threshold
|
||||
is reached (similar to throttles). Thanks @chiliburger!
|
||||
- Default throttled & blocklist responses have Content-Type: text/plain
|
||||
- Rack::Attack.clear! resets tracks
|
||||
|
||||
## v4.0.1 - 14 May 2014
|
||||
* Add throttle discriminator to rack env (thanks @blahed)
|
||||
## [4.0.1] - 2014-05-14
|
||||
- Add throttle discriminator to rack env (thanks @blahed)
|
||||
|
||||
## v4.0.0 - 28 April 2014
|
||||
* Implement proxy for Dalli with better Memcachier support. (thanks @hakanensari)
|
||||
* Rack::Attack.new returns an instance to ease testing. (thanks @stevehodgkiss)
|
||||
## [4.0.0] - 2014-04-28
|
||||
- Implement proxy for Dalli with better Memcachier support. (thanks @hakanensari)
|
||||
- Rack::Attack.new returns an instance to ease testing. (thanks @stevehodgkiss)
|
||||
[Changing a module to a class is not backwards compatible, hence v4.0.0.]
|
||||
* Use Rack::Attack::Request subclass of Rack::Request for easier extending (thanks @tristandunn)
|
||||
* Test more dalli versions.
|
||||
- Use Rack::Attack::Request subclass of Rack::Request for easier extending (thanks @tristandunn)
|
||||
- Test more dalli versions.
|
||||
|
||||
## v3.0.0 - 15 March 2014
|
||||
* Change default blocklisted response to 403 Forbidden (thanks @carpodaster).
|
||||
* Fail gracefully when Redis store is not available; rescue exeption and don't
|
||||
## [3.0.0] - 2014-03-15
|
||||
- Change default blocklisted response to 403 Forbidden (thanks @carpodaster).
|
||||
- Fail gracefully when Redis store is not available; rescue exeption and don't
|
||||
throttle request. (thanks @wkimeria)
|
||||
* TravisCI runs integration tests.
|
||||
- TravisCI runs integration tests.
|
||||
|
||||
## v2.3.0 - 11 October 2013
|
||||
* Allow throttle `limit` argument to be a proc. (thanks @lunks)
|
||||
* Add Allow2Ban, complement of Fail2Ban. (thanks @jormon)
|
||||
* Improved TravisCI testing
|
||||
## [2.3.0] - 2013-10-11
|
||||
- Allow throttle `limit` argument to be a proc. (thanks @lunks)
|
||||
- Add Allow2Ban, complement of Fail2Ban. (thanks @jormon)
|
||||
- Improved TravisCI testing
|
||||
|
||||
## v2.2.1 - 13 August 2013
|
||||
* Add license to gemspec
|
||||
* Support ruby version 1.9.2
|
||||
* Change default blocklisted response code from 503 to 401; throttled response
|
||||
## [2.2.1] - 2013-08-13
|
||||
- Add license to gemspec
|
||||
- Support ruby version 1.9.2
|
||||
- Change default blocklisted response code from 503 to 401; throttled response
|
||||
from 503 to 429.
|
||||
|
||||
## v2.2.0 - 20 June 2013
|
||||
* Fail2Ban filtering. See README for details. Thx @madlep!
|
||||
* Introduce StoreProxy to more cleanly abstract cache stores. Thx @madlep.
|
||||
## [2.2.0] - 2013-06-20
|
||||
- Fail2Ban filtering. See README for details. Thx @madlep!
|
||||
- Introduce StoreProxy to more cleanly abstract cache stores. Thx @madlep.
|
||||
|
||||
## v2.1.1 - 16 May 2013
|
||||
* Start keeping changelog
|
||||
* Fix `Redis::CommandError` when using ActiveSupport numeric extensions (e.g. `1.second`)
|
||||
* Remove unused variable
|
||||
* Extract mandatory options to constants
|
||||
## 2.1.1 - 2013-05-16
|
||||
- Start keeping changelog
|
||||
- Fix `Redis::CommandError` when using ActiveSupport numeric extensions (e.g. `1.second`)
|
||||
- Remove unused variable
|
||||
- Extract mandatory options to constants
|
||||
|
||||
[Unreleased]: https://github.com/kickstarter/rack-attack/compare/v5.3.2...HEAD/
|
||||
[5.3.2]: https://github.com/kickstarter/rack-attack/compare/v5.3.1...v5.3.2/
|
||||
[5.3.1]: https://github.com/kickstarter/rack-attack/compare/v5.3.0...v5.3.1/
|
||||
[5.3.0]: https://github.com/kickstarter/rack-attack/compare/v5.2.0...v5.3.0/
|
||||
[5.2.0]: https://github.com/kickstarter/rack-attack/compare/v5.1.0...v5.2.0/
|
||||
[5.1.0]: https://github.com/kickstarter/rack-attack/compare/v5.0.1...v5.1.0/
|
||||
[5.0.1]: https://github.com/kickstarter/rack-attack/compare/v5.0.0...v5.0.1/
|
||||
[5.0.0]: https://github.com/kickstarter/rack-attack/compare/v4.4.1...v5.0.0/
|
||||
[4.4.1]: https://github.com/kickstarter/rack-attack/compare/v4.4.0...v4.4.1/
|
||||
[4.4.0]: https://github.com/kickstarter/rack-attack/compare/v4.3.1...v4.4.0/
|
||||
[4.3.1]: https://github.com/kickstarter/rack-attack/compare/v4.3.0...v4.3.1/
|
||||
[4.3.0]: https://github.com/kickstarter/rack-attack/compare/v4.2.0...v4.3.0/
|
||||
[4.2.0]: https://github.com/kickstarter/rack-attack/compare/v4.1.1...v4.2.0/
|
||||
[4.1.1]: https://github.com/kickstarter/rack-attack/compare/v4.1.0...v4.1.1/
|
||||
[4.1.0]: https://github.com/kickstarter/rack-attack/compare/v4.0.1...v4.1.0/
|
||||
[4.0.1]: https://github.com/kickstarter/rack-attack/compare/v4.0.0...v4.0.1/
|
||||
[4.0.0]: https://github.com/kickstarter/rack-attack/compare/v3.0.0...v4.0.0/
|
||||
[3.0.0]: https://github.com/kickstarter/rack-attack/compare/v2.3.0...v3.0.0/
|
||||
[2.3.0]: https://github.com/kickstarter/rack-attack/compare/v2.2.1...v2.3.0/
|
||||
[2.2.1]: https://github.com/kickstarter/rack-attack/compare/v2.2.0...v2.2.1/
|
||||
[2.2.0]: https://github.com/kickstarter/rack-attack/compare/v2.1.1...v2.2.0/
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ Instances of abusive, harassing, or otherwise unacceptable behavior may be repor
|
|||
|
||||
:hand: :page_with_curl:
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org) (v1.0.0), available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org) (v1.0.0), available at [https://www.contributor-covenant.org/version/1/0/0/](https://www.contributor-covenant.org/version/1/0/0/)
|
||||
|
|
|
|||
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'
|
||||
|
||||
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::Attack is a rack middleware to protect your web app from bad clients.
|
||||
It allows *safelisting*, *blocklisting*, *throttling*, and *tracking* based on arbitrary properties of the request.
|
||||
Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to *allow*, *block* and *throttle* based on properties of the request.
|
||||
|
||||
Throttle and fail2ban state is stored in a configurable cache (e.g. `Rails.cache`), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)).
|
||||
See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack.
|
||||
|
||||
See the [Backing & Hacking blog post](http://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack.
|
||||
|
||||
[](http://badge.fury.io/rb/rack-attack)
|
||||
[](https://badge.fury.io/rb/rack-attack)
|
||||
[](https://travis-ci.org/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
|
||||
|
||||
Install the [rack-attack](http://rubygems.org/gems/rack-attack) gem; or add it to your Gemfile with bundler:
|
||||
### 1. Installing
|
||||
|
||||
Add this line to your application's Gemfile:
|
||||
|
||||
```ruby
|
||||
# In your Gemfile
|
||||
|
||||
gem 'rack-attack'
|
||||
```
|
||||
Tell your app to use the Rack::Attack middleware.
|
||||
For Rails apps:
|
||||
|
||||
And then execute:
|
||||
|
||||
$ bundle
|
||||
|
||||
Or install it yourself as:
|
||||
|
||||
$ gem install rack-attack
|
||||
|
||||
### 2. Plugging into the application
|
||||
|
||||
Then tell your ruby web application to use rack-attack as a middleware.
|
||||
|
||||
a) For __rails__ applications:
|
||||
|
||||
```ruby
|
||||
# In config/application.rb
|
||||
|
||||
config.middleware.use Rack::Attack
|
||||
```
|
||||
|
||||
Or for Rackup files:
|
||||
b) For __rack__ applications:
|
||||
|
||||
```ruby
|
||||
# In config.ru
|
||||
|
||||
require "rack/attack"
|
||||
use Rack::Attack
|
||||
```
|
||||
|
||||
Add a `rack-attack.rb` file to `config/initializers/`:
|
||||
```ruby
|
||||
# In config/initializers/rack-attack.rb
|
||||
class Rack::Attack
|
||||
# your custom configuration...
|
||||
end
|
||||
```
|
||||
__IMPORTANT__: By default, rack-attack won't perform any blocking or throttling, until you specifically tell it what to protect against by configuring some rules.
|
||||
|
||||
## Usage
|
||||
|
||||
*Tip:* The example in the wiki is a great way to get started:
|
||||
[Example Configuration](https://github.com/kickstarter/rack-attack/wiki/Example-Configuration)
|
||||
|
||||
Optionally configure the cache store for throttling or fail2ban filtering:
|
||||
Define rules by calling `Rack::Attack` public methods, in any file that runs when your application is being initialized. For rails applications this means creating a new file named `config/initializers/rack_attack.rb` and writing your rules there.
|
||||
|
||||
### Safelisting
|
||||
|
||||
Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles.
|
||||
|
||||
#### `safelist_ip(ip_address_string)`
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails app)
|
||||
|
||||
Rack::Attack.safelist_ip("5.6.7.8")
|
||||
```
|
||||
|
||||
#### `safelist_ip(ip_subnet_string)`
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails app)
|
||||
|
||||
Rack::Attack.safelist_ip("5.6.7.0/24")
|
||||
```
|
||||
|
||||
#### `safelist(name, &block)`
|
||||
|
||||
Name your custom safelist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise.
|
||||
|
||||
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails apps)
|
||||
|
||||
# Provided that trusted users use an HTTP request header named APIKey
|
||||
Rack::Attack.safelist("mark any authenticated access safe") do |request|
|
||||
# Requests are allowed if the return value is truthy
|
||||
request.env["APIKey"] == "secret-string"
|
||||
end
|
||||
|
||||
# Always allow requests from localhost
|
||||
# (blocklist & throttles are skipped)
|
||||
Rack::Attack.safelist('allow from localhost') do |req|
|
||||
# Requests are allowed if the return value is truthy
|
||||
'127.0.0.1' == req.ip || '::1' == req.ip
|
||||
end
|
||||
```
|
||||
|
||||
### Blocking
|
||||
|
||||
#### `blocklist_ip(ip_address_string)`
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails apps)
|
||||
|
||||
Rack::Attack.blocklist_ip("1.2.3.4")
|
||||
```
|
||||
|
||||
#### `blocklist_ip(ip_subnet_string)`
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails apps)
|
||||
|
||||
Rack::Attack.blocklist_ip("1.2.0.0/16")
|
||||
```
|
||||
|
||||
#### `blocklist(name, &block)`
|
||||
|
||||
Name your custom blocklist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise.
|
||||
|
||||
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails apps)
|
||||
|
||||
Rack::Attack.blocklist("block all access to admin") do |request|
|
||||
# Requests are blocked if the return value is truthy
|
||||
request.path.start_with?("/admin")
|
||||
end
|
||||
|
||||
Rack::Attack.blocklist('block bad UA logins') do |req|
|
||||
req.path == '/login' && req.post? && req.user_agent == 'BadUA'
|
||||
end
|
||||
```
|
||||
|
||||
#### Fail2Ban
|
||||
|
||||
`Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients.
|
||||
This pattern is inspired by [fail2ban](https://www.fail2ban.org/wiki/index.php/Main_Page).
|
||||
See the [fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on
|
||||
how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter.
|
||||
|
||||
Fail2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
|
||||
|
||||
```ruby
|
||||
# Block suspicious requests for '/etc/password' or wordpress specific paths.
|
||||
# After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes.
|
||||
Rack::Attack.blocklist('fail2ban pentesters') do |req|
|
||||
# `filter` returns truthy value if request fails, or if it's from a previously banned IP
|
||||
# so the request is blocked
|
||||
Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do
|
||||
# The count for the IP is incremented if the return value is truthy
|
||||
CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
|
||||
req.path.include?('/etc/passwd') ||
|
||||
req.path.include?('wp-admin') ||
|
||||
req.path.include?('wp-login')
|
||||
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`.
|
||||
|
||||
#### Allow2Ban
|
||||
|
||||
`Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving
|
||||
clients until such time as they reach maxretry at which they are cut off as per normal.
|
||||
|
||||
Allow2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
|
||||
|
||||
```ruby
|
||||
# Lockout IP addresses that are hammering your login page.
|
||||
# After 20 requests in 1 minute, block all requests from that IP for 1 hour.
|
||||
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
|
||||
# `filter` returns false value if request is to your login page (but still
|
||||
# increments the count) so request below the limit are not blocked until
|
||||
# they hit the limit. At that point, filter will return true and block.
|
||||
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
|
||||
# The count for the IP is incremented if the return value is truthy.
|
||||
req.path == '/login' and req.post?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Throttling
|
||||
|
||||
Throttle state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
|
||||
|
||||
#### `throttle(name, options, &block)`
|
||||
|
||||
Name your custom throttle, provide `limit` and `period` as options, and make your ruby-block argument return the __discriminator__. This discriminator is how you tell rack-attack whether you're limiting per IP address, per user email or any other.
|
||||
|
||||
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
|
||||
|
||||
E.g.
|
||||
|
||||
```ruby
|
||||
# config/initializers/rack_attack.rb (for rails apps)
|
||||
|
||||
Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request|
|
||||
request.ip
|
||||
end
|
||||
|
||||
# Throttle login attempts for a given email parameter to 6 reqs/minute
|
||||
# Return the email as a discriminator on POST /login requests
|
||||
Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req|
|
||||
if req.path == '/login' && req.post?
|
||||
req.params['email']
|
||||
end
|
||||
end
|
||||
|
||||
# You can also set a limit and period using a proc. For instance, after
|
||||
# Rack::Auth::Basic has authenticated the user:
|
||||
limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 }
|
||||
period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 }
|
||||
|
||||
Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request|
|
||||
request.ip
|
||||
end
|
||||
```
|
||||
|
||||
### Tracks
|
||||
|
||||
```ruby
|
||||
# Track requests from a special user agent.
|
||||
Rack::Attack.track("special_agent") do |req|
|
||||
req.user_agent == "SpecialAgent"
|
||||
end
|
||||
|
||||
# Supports optional limit and period, triggers the notification only when the limit is reached.
|
||||
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
|
||||
req.user_agent == "SpecialAgent"
|
||||
end
|
||||
|
||||
# Track it using ActiveSupport::Notification
|
||||
ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, payload|
|
||||
req = payload[:request]
|
||||
if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
|
||||
Rails.logger.info "special_agent: #{req.path}"
|
||||
STATSD.increment("special_agent")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Cache store configuration
|
||||
|
||||
Throttle, allow2ban and fail2ban state is stored in a configurable cache (which defaults to `Rails.cache` if present), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)).
|
||||
|
||||
```ruby
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache
|
||||
```
|
||||
|
||||
Note that `Rack::Attack.cache` is only used for throttling and fail2ban filtering; not blocklisting & safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html).
|
||||
Note that `Rack::Attack.cache` is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html).
|
||||
|
||||
## Customizing responses
|
||||
|
||||
Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC).
|
||||
|
||||
```ruby
|
||||
Rack::Attack.blocklisted_response = lambda do |env|
|
||||
# Using 503 because it may make attacker think that they have successfully
|
||||
# DOSed the site. Rack::Attack returns 403 for blocklists by default
|
||||
[ 503, {}, ['Blocked']]
|
||||
end
|
||||
|
||||
Rack::Attack.throttled_response = lambda do |env|
|
||||
# NB: you have access to the name and other data about the matched throttle
|
||||
# env['rack.attack.matched'],
|
||||
# env['rack.attack.match_type'],
|
||||
# env['rack.attack.match_data'],
|
||||
# env['rack.attack.match_discriminator']
|
||||
|
||||
# Using 503 because it may make attacker think that they have successfully
|
||||
# DOSed the site. Rack::Attack returns 429 for throttling by default
|
||||
[ 503, {}, ["Server Error\n"]]
|
||||
end
|
||||
```
|
||||
|
||||
### X-RateLimit headers for well-behaved clients
|
||||
|
||||
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
|
||||
can also be used to return rate limit data that's helpful for well-behaved clients.
|
||||
|
||||
Here's an example response that includes conventional `X-RateLimit-*` headers:
|
||||
|
||||
```ruby
|
||||
Rack::Attack.throttled_response = lambda do |env|
|
||||
match_data = env['rack.attack.match_data']
|
||||
now = match_data[:epoch_time]
|
||||
|
||||
headers = {
|
||||
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
|
||||
}
|
||||
|
||||
[ 429, headers, ["Throttled\n"]]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
|
||||
|
||||
```ruby
|
||||
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
|
||||
```
|
||||
|
||||
## Logging & Instrumentation
|
||||
|
||||
Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available.
|
||||
|
||||
You can subscribe to 'rack.attack' events and log it, graph it, etc:
|
||||
|
||||
```ruby
|
||||
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, payload|
|
||||
puts payload[:request].inspect
|
||||
end
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
|
|
@ -91,200 +368,10 @@ Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you
|
|||
can cleanly monkey patch helper methods onto the
|
||||
[request object](https://github.com/kickstarter/rack-attack/blob/master/lib/rack/attack/request.rb).
|
||||
|
||||
## About Tracks
|
||||
### About Tracks
|
||||
|
||||
`Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes.
|
||||
|
||||
## Usage
|
||||
|
||||
Define safelists, blocklists, throttles, and tracks as blocks that return truthy values if matched, falsy otherwise. In a Rails app
|
||||
these go in an initializer in `config/initializers/`.
|
||||
A [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request) object is passed to the block (named 'req' in the examples).
|
||||
|
||||
### Safelists
|
||||
|
||||
```ruby
|
||||
# Always allow requests from localhost
|
||||
# (blocklist & throttles are skipped)
|
||||
Rack::Attack.safelist('allow from localhost') do |req|
|
||||
# Requests are allowed if the return value is truthy
|
||||
'127.0.0.1' == req.ip || '::1' == req.ip
|
||||
end
|
||||
```
|
||||
|
||||
### Blocklists
|
||||
|
||||
```ruby
|
||||
# Block requests from 1.2.3.4
|
||||
Rack::Attack.blocklist('block 1.2.3.4') do |req|
|
||||
# Requests are blocked if the return value is truthy
|
||||
'1.2.3.4' == req.ip
|
||||
end
|
||||
|
||||
# Block logins from a bad user agent
|
||||
Rack::Attack.blocklist('block bad UA logins') do |req|
|
||||
req.path == '/login' && req.post? && req.user_agent == 'BadUA'
|
||||
end
|
||||
```
|
||||
|
||||
#### Fail2Ban
|
||||
|
||||
`Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients.
|
||||
This pattern is inspired by [fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page).
|
||||
See the [fail2ban documentation](http://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on
|
||||
how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter.
|
||||
|
||||
```ruby
|
||||
# Block suspicious requests for '/etc/password' or wordpress specific paths.
|
||||
# After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes.
|
||||
Rack::Attack.blocklist('fail2ban pentesters') do |req|
|
||||
# `filter` returns truthy value if request fails, or if it's from a previously banned IP
|
||||
# so the request is blocked
|
||||
Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do
|
||||
# The count for the IP is incremented if the return value is truthy
|
||||
CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
|
||||
req.path.include?('/etc/passwd') ||
|
||||
req.path.include?('wp-admin') ||
|
||||
req.path.include?('wp-login')
|
||||
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`.
|
||||
|
||||
#### Allow2Ban
|
||||
`Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving
|
||||
clients until such time as they reach maxretry at which they are cut off as per normal.
|
||||
```ruby
|
||||
# Lockout IP addresses that are hammering your login page.
|
||||
# After 20 requests in 1 minute, block all requests from that IP for 1 hour.
|
||||
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
|
||||
# `filter` returns false value if request is to your login page (but still
|
||||
# increments the count) so request below the limit are not blocked until
|
||||
# they hit the limit. At that point, filter will return true and block.
|
||||
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
|
||||
# The count for the IP is incremented if the return value is truthy.
|
||||
req.path == '/login' and req.post?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
### Throttles
|
||||
|
||||
```ruby
|
||||
# Throttle requests to 5 requests per second per ip
|
||||
Rack::Attack.throttle('req/ip', limit: 5, period: 1.second) do |req|
|
||||
# If the return value is truthy, the cache key for the return value
|
||||
# is incremented and compared with the limit. In this case:
|
||||
# "rack::attack:#{Time.now.to_i/1.second}:req/ip:#{req.ip}"
|
||||
#
|
||||
# If falsy, the cache key is neither incremented nor checked.
|
||||
|
||||
req.ip
|
||||
end
|
||||
|
||||
# Throttle login attempts for a given email parameter to 6 reqs/minute
|
||||
# Return the email as a discriminator on POST /login requests
|
||||
Rack::Attack.throttle('logins/email', limit: 6, period: 60) do |req|
|
||||
req.params['email'] if req.path == '/login' && req.post?
|
||||
end
|
||||
|
||||
# You can also set a limit and period using a proc. For instance, after
|
||||
# Rack::Auth::Basic has authenticated the user:
|
||||
limit_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 100 : 1}
|
||||
period_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 1.second : 1.minute}
|
||||
Rack::Attack.throttle('req/ip', limit: limit_proc, period: period_proc) do |req|
|
||||
req.ip
|
||||
end
|
||||
```
|
||||
|
||||
### Tracks
|
||||
|
||||
```ruby
|
||||
# Track requests from a special user agent.
|
||||
Rack::Attack.track("special_agent") do |req|
|
||||
req.user_agent == "SpecialAgent"
|
||||
end
|
||||
|
||||
# Supports optional limit and period, triggers the notification only when the limit is reached.
|
||||
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
|
||||
req.user_agent == "SpecialAgent"
|
||||
end
|
||||
|
||||
# Track it using ActiveSupport::Notification
|
||||
ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, req|
|
||||
if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
|
||||
Rails.logger.info "special_agent: #{req.path}"
|
||||
STATSD.increment("special_agent")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Responses
|
||||
|
||||
Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://rack.rubyforge.org/doc/SPEC.html).
|
||||
|
||||
```ruby
|
||||
Rack::Attack.blocklisted_response = lambda do |env|
|
||||
# Using 503 because it may make attacker think that they have successfully
|
||||
# DOSed the site. Rack::Attack returns 403 for blocklists by default
|
||||
[ 503, {}, ['Blocked']]
|
||||
end
|
||||
|
||||
Rack::Attack.throttled_response = lambda do |env|
|
||||
# NB: you have access to the name and other data about the matched throttle
|
||||
# env['rack.attack.matched'],
|
||||
# env['rack.attack.match_type'],
|
||||
# env['rack.attack.match_data']
|
||||
|
||||
# Using 503 because it may make attacker think that they have successfully
|
||||
# DOSed the site. Rack::Attack returns 429 for throttling by default
|
||||
[ 503, {}, ["Server Error\n"]]
|
||||
end
|
||||
```
|
||||
|
||||
### X-RateLimit headers for well-behaved clients
|
||||
|
||||
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
|
||||
can also be used to return rate limit data that's helpful for well-behaved clients.
|
||||
|
||||
Here's an example response that includes conventional `X-RateLimit-*` headers:
|
||||
|
||||
```ruby
|
||||
Rack::Attack.throttled_response = lambda do |env|
|
||||
now = Time.now
|
||||
match_data = env['rack.attack.match_data']
|
||||
|
||||
headers = {
|
||||
'X-RateLimit-Limit' => match_data[:limit].to_s,
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
|
||||
}
|
||||
|
||||
[ 429, headers, ["Throttled\n"]]
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
|
||||
|
||||
```ruby
|
||||
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l }
|
||||
```
|
||||
|
||||
## Logging & Instrumentation
|
||||
|
||||
Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available.
|
||||
|
||||
You can subscribe to 'rack.attack' events and log it, graph it, etc:
|
||||
|
||||
```ruby
|
||||
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
|
||||
puts req.inspect
|
||||
end
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
@ -302,7 +389,7 @@ so try to keep the number of throttle checks per request low.
|
|||
If a request is blocklisted or throttled, the response is a very simple Rack response.
|
||||
A single typical ruby web server thread can block several hundred requests per second.
|
||||
|
||||
Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone).
|
||||
Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone).
|
||||
|
||||
## Motivation
|
||||
|
||||
|
|
@ -316,26 +403,15 @@ less on short-term, one-off hacks to block a particular attack.
|
|||
|
||||
## Contributing
|
||||
|
||||
Pull requests and issues are greatly appreciated. This project is intended to be
|
||||
a safe, welcoming space for collaboration, and contributors are expected to
|
||||
adhere to the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
Check out the [Contributing guide](CONTRIBUTING.md).
|
||||
|
||||
### Testing pull requests
|
||||
## Code of Conduct
|
||||
|
||||
To run the minitest test suite, you will need both [Redis](http://redis.io/) and
|
||||
[Memcached](https://memcached.org/) running locally and bound to IP `127.0.0.1` on
|
||||
default ports (`6379` for Redis, and `11211` for Memcached) and able to be
|
||||
accessed without authentication.
|
||||
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
Install dependencies by running
|
||||
```sh
|
||||
bundle install
|
||||
```
|
||||
## Development setup
|
||||
|
||||
Then run the test suite by running
|
||||
```sh
|
||||
bundle exec rake
|
||||
```
|
||||
Check out the [Development guide](docs/development.md).
|
||||
|
||||
## Mailing list
|
||||
|
||||
|
|
@ -348,4 +424,4 @@ New releases of Rack::Attack are announced on
|
|||
|
||||
Copyright Kickstarter, PBC.
|
||||
|
||||
Released under an [MIT License](http://opensource.org/licenses/MIT).
|
||||
Released under an [MIT License](https://opensource.org/licenses/MIT).
|
||||
|
|
|
|||
16
Rakefile
16
Rakefile
|
|
@ -1,7 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rubygems"
|
||||
require "bundler/setup"
|
||||
require 'bundler/gem_tasks'
|
||||
require 'rake/testtask'
|
||||
require "rubocop/rake_task"
|
||||
|
||||
RuboCop::RakeTask.new
|
||||
|
||||
namespace :test do
|
||||
Rake::TestTask.new(:units) do |t|
|
||||
|
|
@ -11,9 +16,14 @@ namespace :test do
|
|||
Rake::TestTask.new(:integration) do |t|
|
||||
t.pattern = "spec/integration/*_spec.rb"
|
||||
end
|
||||
|
||||
Rake::TestTask.new(:acceptance) do |t|
|
||||
t.pattern = "spec/acceptance/**/*_spec.rb"
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Run tests'
|
||||
task :test => %w[test:units test:integration]
|
||||
Rake::TestTask.new(:test) do |t|
|
||||
t.pattern = "spec/**/*_spec.rb"
|
||||
end
|
||||
|
||||
task :default => :test
|
||||
task :default => [:rubocop, :test]
|
||||
|
|
|
|||
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)
|
||||
|
||||
# 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
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "dalli", "~> 2.0"
|
||||
|
||||
group :development do
|
||||
gem "pry"
|
||||
gem "guard"
|
||||
gem "guard-minitest"
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "activesupport", "~> 4.2.0"
|
||||
gem "actionpack", "~> 4.2.0"
|
||||
|
||||
group :development do
|
||||
gem "pry"
|
||||
gem "guard"
|
||||
gem "guard-minitest"
|
||||
end
|
||||
gem "activesupport", "~> 4.2.0"
|
||||
gem "rack-test", ">= 0.6"
|
||||
|
||||
gemspec path: "../"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "activesupport", "~> 5.1.0"
|
||||
gem "actionpack", "~> 5.1.0"
|
||||
|
||||
group :development do
|
||||
gem "pry"
|
||||
gem "guard"
|
||||
gem "guard-minitest"
|
||||
end
|
||||
gem "activesupport", "~> 5.1.0"
|
||||
|
||||
gemspec path: "../"
|
||||
|
|
|
|||
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 'forwardable'
|
||||
require 'rack/attack/path_normalizer'
|
||||
require 'rack/attack/request'
|
||||
require "ipaddr"
|
||||
|
||||
class Rack::Attack
|
||||
autoload :Cache, 'rack/attack/cache'
|
||||
autoload :PathNormalizer, 'rack/attack/path_normalizer'
|
||||
autoload :Check, 'rack/attack/check'
|
||||
autoload :Throttle, 'rack/attack/throttle'
|
||||
autoload :Safelist, 'rack/attack/safelist'
|
||||
autoload :Blocklist, 'rack/attack/blocklist'
|
||||
autoload :Track, 'rack/attack/track'
|
||||
autoload :StoreProxy, 'rack/attack/store_proxy'
|
||||
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
||||
autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
|
||||
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
||||
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
||||
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
||||
autoload :Allow2Ban, 'rack/attack/allow2ban'
|
||||
autoload :Request, 'rack/attack/request'
|
||||
class MisconfiguredStoreError < StandardError; end
|
||||
class MissingStoreError < StandardError; end
|
||||
|
||||
autoload :Cache, 'rack/attack/cache'
|
||||
autoload :Check, 'rack/attack/check'
|
||||
autoload :Throttle, 'rack/attack/throttle'
|
||||
autoload :Safelist, 'rack/attack/safelist'
|
||||
autoload :Blocklist, 'rack/attack/blocklist'
|
||||
autoload :Track, 'rack/attack/track'
|
||||
autoload :StoreProxy, 'rack/attack/store_proxy'
|
||||
autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy'
|
||||
autoload :MemCacheProxy, 'rack/attack/store_proxy/mem_cache_proxy'
|
||||
autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy'
|
||||
autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy'
|
||||
autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy'
|
||||
autoload :Fail2Ban, 'rack/attack/fail2ban'
|
||||
autoload :Allow2Ban, 'rack/attack/allow2ban'
|
||||
|
||||
class << self
|
||||
|
||||
attr_accessor :notifier, :blocklisted_response, :throttled_response
|
||||
|
||||
def safelist(name, &block)
|
||||
self.safelists[name] = Safelist.new(name, block)
|
||||
end
|
||||
|
||||
def whitelist(name, &block)
|
||||
warn "[DEPRECATION] 'Rack::Attack.whitelist' is deprecated. Please use 'safelist' instead."
|
||||
safelist(name, &block)
|
||||
end
|
||||
|
||||
def blocklist(name, &block)
|
||||
self.blocklists[name] = Blocklist.new(name, block)
|
||||
end
|
||||
|
||||
def blacklist(name, &block)
|
||||
warn "[DEPRECATION] 'Rack::Attack.blacklist' is deprecated. Please use 'blocklist' instead."
|
||||
blocklist(name, &block)
|
||||
def blocklist_ip(ip_address)
|
||||
@ip_blocklists ||= []
|
||||
ip_blocklist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
||||
@ip_blocklists << Blocklist.new(nil, ip_blocklist_proc)
|
||||
end
|
||||
|
||||
def safelist_ip(ip_address)
|
||||
@ip_safelists ||= []
|
||||
ip_safelist_proc = lambda { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) }
|
||||
@ip_safelists << Safelist.new(nil, ip_safelist_proc)
|
||||
end
|
||||
|
||||
def throttle(name, options, &block)
|
||||
|
|
@ -49,84 +57,71 @@ class Rack::Attack
|
|||
end
|
||||
|
||||
def safelists; @safelists ||= {}; end
|
||||
|
||||
def blocklists; @blocklists ||= {}; end
|
||||
|
||||
def throttles; @throttles ||= {}; end
|
||||
|
||||
def tracks; @tracks ||= {}; end
|
||||
|
||||
def whitelists
|
||||
warn "[DEPRECATION] 'Rack::Attack.whitelists' is deprecated. Please use 'safelists' instead."
|
||||
safelists
|
||||
def safelisted?(request)
|
||||
ip_safelists.any? { |safelist| safelist.matched_by?(request) } ||
|
||||
safelists.any? { |_name, safelist| safelist.matched_by?(request) }
|
||||
end
|
||||
|
||||
def blacklists
|
||||
warn "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead."
|
||||
blocklists
|
||||
def blocklisted?(request)
|
||||
ip_blocklists.any? { |blocklist| blocklist.matched_by?(request) } ||
|
||||
blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) }
|
||||
end
|
||||
|
||||
def safelisted?(req)
|
||||
safelists.any? do |name, safelist|
|
||||
safelist[req]
|
||||
def throttled?(request)
|
||||
throttles.any? do |_name, throttle|
|
||||
throttle.matched_by?(request)
|
||||
end
|
||||
end
|
||||
|
||||
def whitelisted?(req)
|
||||
warn "[DEPRECATION] 'Rack::Attack.whitelisted?' is deprecated. Please use 'safelisted?' instead."
|
||||
safelisted?(req)
|
||||
end
|
||||
|
||||
def blocklisted?(req)
|
||||
blocklists.any? do |name, blocklist|
|
||||
blocklist[req]
|
||||
def tracked?(request)
|
||||
tracks.each_value do |track|
|
||||
track.matched_by?(request)
|
||||
end
|
||||
end
|
||||
|
||||
def blacklisted?(req)
|
||||
warn "[DEPRECATION] 'Rack::Attack.blacklisted?' is deprecated. Please use 'blocklisted?' instead."
|
||||
blocklisted?(req)
|
||||
end
|
||||
|
||||
def throttled?(req)
|
||||
throttles.any? do |name, throttle|
|
||||
throttle[req]
|
||||
end
|
||||
end
|
||||
|
||||
def tracked?(req)
|
||||
tracks.each_value do |tracker|
|
||||
tracker[req]
|
||||
end
|
||||
end
|
||||
|
||||
def instrument(req)
|
||||
notifier.instrument('rack.attack', req) if notifier
|
||||
def instrument(request)
|
||||
notifier.instrument('rack.attack', request: request) if notifier
|
||||
end
|
||||
|
||||
def cache
|
||||
@cache ||= Cache.new
|
||||
end
|
||||
|
||||
def clear!
|
||||
def clear_configuration
|
||||
@safelists, @blocklists, @throttles, @tracks = {}, {}, {}, {}
|
||||
@ip_blocklists = []
|
||||
@ip_safelists = []
|
||||
end
|
||||
|
||||
def blacklisted_response=(res)
|
||||
warn "[DEPRECATION] 'Rack::Attack.blacklisted_response=' is deprecated. Please use 'blocklisted_response=' instead."
|
||||
self.blocklisted_response=(res)
|
||||
def clear!
|
||||
warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead"
|
||||
clear_configuration
|
||||
end
|
||||
|
||||
def blacklisted_response
|
||||
warn "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead."
|
||||
blocklisted_response
|
||||
private
|
||||
|
||||
def ip_blocklists
|
||||
@ip_blocklists ||= []
|
||||
end
|
||||
|
||||
def ip_safelists
|
||||
@ip_safelists ||= []
|
||||
end
|
||||
end
|
||||
|
||||
# Set defaults
|
||||
@notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications)
|
||||
@blocklisted_response = lambda {|env| [403, {'Content-Type' => 'text/plain'}, ["Forbidden\n"]] }
|
||||
@throttled_response = lambda {|env|
|
||||
@blocklisted_response = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] }
|
||||
@throttled_response = lambda { |env|
|
||||
retry_after = (env['rack.attack.match_data'] || {})[:period]
|
||||
[429, {'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s}, ["Retry later\n"]]
|
||||
[429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]]
|
||||
}
|
||||
|
||||
def initialize(app)
|
||||
|
|
@ -135,23 +130,20 @@ class Rack::Attack
|
|||
|
||||
def call(env)
|
||||
env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO'])
|
||||
req = Rack::Attack::Request.new(env)
|
||||
request = Rack::Attack::Request.new(env)
|
||||
|
||||
if safelisted?(req)
|
||||
if safelisted?(request)
|
||||
@app.call(env)
|
||||
elsif blocklisted?(req)
|
||||
elsif blocklisted?(request)
|
||||
self.class.blocklisted_response.call(env)
|
||||
elsif throttled?(req)
|
||||
elsif throttled?(request)
|
||||
self.class.throttled_response.call(env)
|
||||
else
|
||||
tracked?(req)
|
||||
tracked?(request)
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
||||
extend Forwardable
|
||||
def_delegators self, :safelisted?,
|
||||
:blocklisted?,
|
||||
:throttled?,
|
||||
:tracked?
|
||||
def_delegators self, :safelisted?, :blocklisted?, :throttled?, :tracked?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Allow2Ban < Fail2Ban
|
||||
class << self
|
||||
protected
|
||||
|
||||
def key_prefix
|
||||
'allow2ban'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Blocklist < Check
|
||||
|
|
@ -5,7 +7,6 @@ module Rack
|
|||
super
|
||||
@type = :blocklist
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Cache
|
||||
|
||||
attr_accessor :prefix
|
||||
attr_reader :last_epoch_time
|
||||
|
||||
def initialize
|
||||
self.store = ::Rails.cache if defined?(::Rails.cache)
|
||||
|
|
@ -20,6 +22,9 @@ module Rack
|
|||
end
|
||||
|
||||
def read(unprefixed_key)
|
||||
enforce_store_presence!
|
||||
enforce_store_method_presence!(:read)
|
||||
|
||||
store.read("#{prefix}:#{unprefixed_key}")
|
||||
end
|
||||
|
||||
|
|
@ -39,22 +44,38 @@ module Rack
|
|||
private
|
||||
|
||||
def key_and_expiry(unprefixed_key, period)
|
||||
epoch_time = Time.now.to_i
|
||||
# Add 1 to expires_in to avoid timing error: http://git.io/i1PHXA
|
||||
expires_in = (period - (epoch_time % period) + 1).to_i
|
||||
["#{prefix}:#{(epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
|
||||
@last_epoch_time = Time.now.to_i
|
||||
# Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA
|
||||
expires_in = (period - (@last_epoch_time % period) + 1).to_i
|
||||
["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in]
|
||||
end
|
||||
|
||||
def do_count(key, expires_in)
|
||||
enforce_store_presence!
|
||||
enforce_store_method_presence!(:increment)
|
||||
|
||||
result = store.increment(key, 1, :expires_in => expires_in)
|
||||
|
||||
# NB: Some stores return nil when incrementing uninitialized values
|
||||
if result.nil?
|
||||
enforce_store_method_presence!(:write)
|
||||
|
||||
store.write(key, 1, :expires_in => expires_in)
|
||||
end
|
||||
result || 1
|
||||
end
|
||||
|
||||
def enforce_store_presence!
|
||||
if store.nil?
|
||||
raise Rack::Attack::MissingStoreError
|
||||
end
|
||||
end
|
||||
|
||||
def enforce_store_method_presence!(method_name)
|
||||
if !store.respond_to?(method_name)
|
||||
raise Rack::Attack::MisconfiguredStoreError, "Store needs to respond to ##{method_name}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Check
|
||||
|
|
@ -7,17 +9,15 @@ module Rack
|
|||
@type = options.fetch(:type, nil)
|
||||
end
|
||||
|
||||
def [](req)
|
||||
block[req].tap {|match|
|
||||
def matched_by?(request)
|
||||
block.call(request).tap do |match|
|
||||
if match
|
||||
req.env["rack.attack.matched"] = name
|
||||
req.env["rack.attack.match_type"] = type
|
||||
Rack::Attack.instrument(req)
|
||||
request.env["rack.attack.matched"] = name
|
||||
request.env["rack.attack.match_type"] = type
|
||||
Rack::Attack.instrument(request)
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Fail2Ban
|
||||
|
|
@ -27,6 +29,7 @@ module Rack
|
|||
end
|
||||
|
||||
protected
|
||||
|
||||
def key_prefix
|
||||
'fail2ban'
|
||||
end
|
||||
|
|
@ -40,8 +43,8 @@ module Rack
|
|||
true
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def ban!(discriminator, bantime)
|
||||
cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
class Rack::Attack
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Rack::Attack
|
||||
# When using Rack::Attack with a Rails app, developers expect the request path
|
||||
# to be normalized. In particular, trailing slashes are stripped.
|
||||
# (See http://git.io/v0rrR for implementation.)
|
||||
# (See https://git.io/v0rrR for implementation.)
|
||||
#
|
||||
# Look for an ActionDispatch utility class that Rails folks would expect
|
||||
# to normalize request paths. If unavailable, use a fallback class that
|
||||
|
|
@ -15,10 +16,9 @@ class Rack::Attack
|
|||
end
|
||||
|
||||
PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils)
|
||||
# For Rails apps
|
||||
::ActionDispatch::Journey::Router::Utils
|
||||
else
|
||||
FallbackPathNormalizer
|
||||
end
|
||||
|
||||
# For Rails apps
|
||||
::ActionDispatch::Journey::Router::Utils
|
||||
else
|
||||
FallbackPathNormalizer
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Rack::Attack::Request is the same as ::Rack::Request by default.
|
||||
#
|
||||
# This is a safe place to add custom helper methods to the request object
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Safelist < Check
|
||||
|
|
@ -5,7 +7,6 @@ module Rack
|
|||
super
|
||||
@type = :safelist
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
module StoreProxy
|
||||
PROXIES = [DalliProxy, MemCacheProxy, RedisStoreProxy, RedisProxy].freeze
|
||||
PROXIES = [DalliProxy, MemCacheProxy, RedisProxy, RedisStoreProxy, RedisCacheStoreProxy].freeze
|
||||
|
||||
ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore']).freeze
|
||||
ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Redis', 'Dalli::Client', 'MemCache']).freeze
|
||||
ACTIVE_SUPPORT_WRAPPER_CLASSES = Set.new(['ActiveSupport::Cache::MemCacheStore', 'ActiveSupport::Cache::RedisStore', 'ActiveSupport::Cache::RedisCacheStore']).freeze
|
||||
ACTIVE_SUPPORT_CLIENTS = Set.new(['Redis::Store', 'Dalli::Client', 'MemCache']).freeze
|
||||
|
||||
def self.build(store)
|
||||
client = unwrap_active_support_stores(store)
|
||||
|
|
@ -12,8 +14,6 @@ module Rack
|
|||
klass ? klass.new(client) : client
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
def self.unwrap_active_support_stores(store)
|
||||
# ActiveSupport::Cache::RedisStore doesn't expose any way to set an expiry,
|
||||
# so use the raw Redis::Store instead.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'delegate'
|
||||
|
||||
module Rack
|
||||
|
|
@ -28,14 +30,14 @@ module Rack
|
|||
rescue Dalli::DalliError
|
||||
end
|
||||
|
||||
def write(key, value, options={})
|
||||
def write(key, value, options = {})
|
||||
with do |client|
|
||||
client.set(key, value, options.fetch(:expires_in, 0), raw: true)
|
||||
end
|
||||
rescue Dalli::DalliError
|
||||
end
|
||||
|
||||
def increment(key, amount, options={})
|
||||
def increment(key, amount, options = {})
|
||||
with do |client|
|
||||
client.incr(key, amount, options.fetch(:expires_in, 0), amount)
|
||||
end
|
||||
|
|
@ -58,7 +60,6 @@ module Rack
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
module StoreProxy
|
||||
|
|
@ -14,21 +16,21 @@ module Rack
|
|||
def read(key)
|
||||
# Second argument: reading raw value
|
||||
get(key, true)
|
||||
rescue MemCache::MemCacheError
|
||||
rescue MemCache::MemCacheError
|
||||
end
|
||||
|
||||
def write(key, value, options={})
|
||||
def write(key, value, options = {})
|
||||
# Third argument: writing raw value
|
||||
set(key, value, options.fetch(:expires_in, 0), true)
|
||||
rescue MemCache::MemCacheError
|
||||
end
|
||||
|
||||
def increment(key, amount, options={})
|
||||
def increment(key, amount, _options = {})
|
||||
incr(key, amount)
|
||||
rescue MemCache::MemCacheError
|
||||
end
|
||||
|
||||
def delete(key, options={})
|
||||
def delete(key, _options = {})
|
||||
with do |client|
|
||||
client.delete(key)
|
||||
end
|
||||
|
|
@ -44,7 +46,6 @@ module Rack
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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'
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
module StoreProxy
|
||||
class RedisStoreProxy < SimpleDelegator
|
||||
def self.handle?(store)
|
||||
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
||||
def initialize(*args)
|
||||
if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3")
|
||||
warn 'RackAttack requires Redis gem >= 3.0.0.'
|
||||
end
|
||||
|
||||
super(*args)
|
||||
end
|
||||
|
||||
def initialize(store)
|
||||
super(store)
|
||||
def self.handle?(store)
|
||||
defined?(::Redis::Store) && store.is_a?(::Redis::Store)
|
||||
end
|
||||
|
||||
def read(key)
|
||||
|
|
@ -17,7 +23,7 @@ module Rack
|
|||
rescue Redis::BaseError
|
||||
end
|
||||
|
||||
def write(key, value, options={})
|
||||
def write(key, value, options = {})
|
||||
if (expires_in = options[:expires_in])
|
||||
setex(key, expires_in, value, raw: true)
|
||||
else
|
||||
|
|
@ -26,7 +32,7 @@ module Rack
|
|||
rescue Redis::BaseError
|
||||
end
|
||||
|
||||
def increment(key, amount, options={})
|
||||
def increment(key, amount, options = {})
|
||||
count = nil
|
||||
|
||||
pipelined do
|
||||
|
|
@ -38,7 +44,7 @@ module Rack
|
|||
rescue Redis::BaseError
|
||||
end
|
||||
|
||||
def delete(key, options={})
|
||||
def delete(key, _options = {})
|
||||
del(key)
|
||||
rescue Redis::BaseError
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Throttle
|
||||
|
|
@ -18,29 +20,32 @@ module Rack
|
|||
Rack::Attack.cache
|
||||
end
|
||||
|
||||
def [](req)
|
||||
discriminator = block[req]
|
||||
def matched_by?(request)
|
||||
discriminator = block.call(request)
|
||||
return false unless discriminator
|
||||
|
||||
current_period = period.respond_to?(:call) ? period.call(req) : period
|
||||
current_limit = limit.respond_to?(:call) ? limit.call(req) : limit
|
||||
current_period = period.respond_to?(:call) ? period.call(request) : period
|
||||
current_limit = limit.respond_to?(:call) ? limit.call(request) : limit
|
||||
key = "#{name}:#{discriminator}"
|
||||
count = cache.count(key, current_period)
|
||||
epoch_time = cache.last_epoch_time
|
||||
|
||||
data = {
|
||||
:count => count,
|
||||
:period => current_period,
|
||||
:limit => current_limit
|
||||
:limit => current_limit,
|
||||
:epoch_time => epoch_time
|
||||
}
|
||||
(req.env['rack.attack.throttle_data'] ||= {})[name] = data
|
||||
|
||||
(request.env['rack.attack.throttle_data'] ||= {})[name] = data
|
||||
|
||||
(count > current_limit).tap do |throttled|
|
||||
if throttled
|
||||
req.env['rack.attack.matched'] = name
|
||||
req.env['rack.attack.match_discriminator'] = discriminator
|
||||
req.env['rack.attack.match_type'] = type
|
||||
req.env['rack.attack.match_data'] = data
|
||||
Rack::Attack.instrument(req)
|
||||
request.env['rack.attack.matched'] = name
|
||||
request.env['rack.attack.match_discriminator'] = discriminator
|
||||
request.env['rack.attack.match_type'] = type
|
||||
request.env['rack.attack.match_data'] = data
|
||||
Rack::Attack.instrument(request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
class Track
|
||||
extend Forwardable
|
||||
|
||||
attr_reader :filter
|
||||
|
||||
def initialize(name, options = {}, block)
|
||||
|
|
@ -15,7 +15,9 @@ module Rack
|
|||
end
|
||||
end
|
||||
|
||||
def_delegator :@filter, :[]
|
||||
def matched_by?(request)
|
||||
filter.matched_by?(request)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Rack
|
||||
class Attack
|
||||
VERSION = '5.0.1'
|
||||
VERSION = '5.3.2'
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
# frozen_string_literal: true
|
||||
|
||||
lib = File.expand_path('../lib/', __FILE__)
|
||||
$:.unshift lib unless $:.include?(lib)
|
||||
|
||||
|
|
@ -14,23 +16,38 @@ Gem::Specification.new do |s|
|
|||
s.email = "aaron@ktheory.com"
|
||||
|
||||
s.files = Dir.glob("{bin,lib}/**/*") + %w(Rakefile README.md)
|
||||
s.homepage = 'http://github.com/kickstarter/rack-attack'
|
||||
s.homepage = 'https://github.com/kickstarter/rack-attack'
|
||||
s.rdoc_options = ["--charset=UTF-8"]
|
||||
s.require_paths = ["lib"]
|
||||
s.summary = %q{Block & throttle abusive requests}
|
||||
s.test_files = Dir.glob("spec/**/*")
|
||||
|
||||
s.required_ruby_version = '>= 2.0.0'
|
||||
s.metadata = {
|
||||
"bug_tracker_uri" => "https://github.com/kickstarter/rack-attack/issues",
|
||||
"changelog_uri" => "https://github.com/kickstarter/rack-attack/blob/master/CHANGELOG.md",
|
||||
"source_code_uri" => "https://github.com/kickstarter/rack-attack"
|
||||
}
|
||||
|
||||
s.add_dependency 'rack'
|
||||
s.add_development_dependency 'minitest'
|
||||
s.add_development_dependency 'rack-test'
|
||||
s.add_development_dependency 'rake'
|
||||
s.add_development_dependency 'appraisal'
|
||||
s.add_development_dependency 'activesupport', '>= 3.0.0'
|
||||
s.add_development_dependency 'actionpack', '>= 3.0.0'
|
||||
s.add_development_dependency 'redis-activesupport'
|
||||
s.add_development_dependency 'dalli'
|
||||
s.add_development_dependency 'connection_pool'
|
||||
s.add_development_dependency 'memcache-client'
|
||||
s.required_ruby_version = '>= 2.3'
|
||||
|
||||
s.add_runtime_dependency 'rack', ">= 1.0", "< 3"
|
||||
|
||||
s.add_development_dependency 'appraisal', '~> 2.2'
|
||||
s.add_development_dependency "bundler", "~> 1.16"
|
||||
s.add_development_dependency 'minitest', "~> 5.11"
|
||||
s.add_development_dependency "minitest-stub-const", "~> 0.6"
|
||||
s.add_development_dependency 'rack-test', "~> 1.0"
|
||||
s.add_development_dependency 'rake', "~> 12.3"
|
||||
s.add_development_dependency "rubocop", "0.57.2"
|
||||
s.add_development_dependency "timecop", "~> 0.9.1"
|
||||
|
||||
# byebug only works with MRI
|
||||
if RUBY_ENGINE == "ruby"
|
||||
s.add_development_dependency 'byebug', '~> 10.0'
|
||||
end
|
||||
|
||||
# The following are potential runtime dependencies users may have,
|
||||
# which rack-attack uses only for testing compatibility in test suite.
|
||||
s.add_development_dependency 'actionpack', '~> 5.2'
|
||||
s.add_development_dependency 'activesupport', '~> 5.2'
|
||||
end
|
||||
|
|
|
|||
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'
|
||||
|
||||
describe 'Rack::Attack.Allow2Ban' do
|
||||
|
|
@ -7,10 +9,10 @@ describe 'Rack::Attack.Allow2Ban' do
|
|||
@findtime = 60
|
||||
@bantime = 60
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
@f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2}
|
||||
@f2b_options = { :bantime => @bantime, :findtime => @findtime, :maxretry => 2 }
|
||||
|
||||
Rack::Attack.blocklist('pentest') do |req|
|
||||
Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/}
|
||||
Rack::Attack::Allow2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ describe 'Rack::Attack.Allow2Ban' do
|
|||
end
|
||||
|
||||
it 'increases fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 1
|
||||
end
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ describe 'Rack::Attack.Allow2Ban' do
|
|||
end
|
||||
|
||||
it 'increases fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
@ -89,7 +91,7 @@ describe 'Rack::Attack.Allow2Ban' do
|
|||
end
|
||||
|
||||
it 'does not increase fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
@ -109,7 +111,7 @@ describe 'Rack::Attack.Allow2Ban' do
|
|||
end
|
||||
|
||||
it 'does not increase fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:allow2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
|
||||
describe 'Rack::Attack.Fail2Ban' do
|
||||
|
|
@ -7,10 +9,10 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
@findtime = 60
|
||||
@bantime = 60
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
@f2b_options = {:bantime => @bantime, :findtime => @findtime, :maxretry => 2}
|
||||
@f2b_options = { :bantime => @bantime, :findtime => @findtime, :maxretry => 2 }
|
||||
|
||||
Rack::Attack.blocklist('pentest') do |req|
|
||||
Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options){req.query_string =~ /OMGHAX/}
|
||||
Rack::Attack::Fail2Ban.filter(req.ip, @f2b_options) { req.query_string =~ /OMGHAX/ }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
end
|
||||
|
||||
it 'increases fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 1
|
||||
end
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
end
|
||||
|
||||
it 'increases fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
end
|
||||
|
||||
it 'resets fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4"
|
||||
assert_nil @cache.store.read(key)
|
||||
end
|
||||
|
||||
|
|
@ -110,7 +112,7 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
end
|
||||
|
||||
it 'does not increase fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
@ -130,7 +132,7 @@ describe 'Rack::Attack.Fail2Ban' do
|
|||
end
|
||||
|
||||
it 'does not increase fail count' do
|
||||
key = "rack::attack:#{Time.now.to_i/@findtime}:fail2ban:count:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4"
|
||||
@cache.store.read(key).must_equal 2
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'active_support/cache'
|
||||
require 'redis-activesupport'
|
||||
require 'dalli'
|
||||
require_relative '../spec_helper'
|
||||
|
||||
OfflineExamples = Minitest::SharedExamples.new do
|
||||
|
|
@ -17,27 +17,31 @@ OfflineExamples = Minitest::SharedExamples.new do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'when Redis is offline' do
|
||||
include OfflineExamples
|
||||
if defined?(::ActiveSupport::Cache::RedisStore)
|
||||
describe 'when Redis is offline' do
|
||||
include OfflineExamples
|
||||
|
||||
before {
|
||||
@cache = Rack::Attack::Cache.new
|
||||
# Use presumably unused port for Redis client
|
||||
@cache.store = ActiveSupport::Cache::RedisStore.new(:host => '127.0.0.1', :port => 3333)
|
||||
}
|
||||
before do
|
||||
@cache = Rack::Attack::Cache.new
|
||||
# Use presumably unused port for Redis client
|
||||
@cache.store = ActiveSupport::Cache::RedisStore.new(:host => '127.0.0.1', :port => 3333)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when Memcached is offline' do
|
||||
include OfflineExamples
|
||||
if defined?(::Dalli)
|
||||
describe 'when Memcached is offline' do
|
||||
include OfflineExamples
|
||||
|
||||
before {
|
||||
Dalli.logger.level = Logger::FATAL
|
||||
before do
|
||||
Dalli.logger.level = Logger::FATAL
|
||||
|
||||
@cache = Rack::Attack::Cache.new
|
||||
@cache.store = Dalli::Client.new('127.0.0.1:22122')
|
||||
}
|
||||
@cache = Rack::Attack::Cache.new
|
||||
@cache.store = Dalli::Client.new('127.0.0.1:22122')
|
||||
end
|
||||
|
||||
after {
|
||||
Dalli.logger.level = Logger::INFO
|
||||
}
|
||||
after do
|
||||
Dalli.logger.level = Logger::INFO
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
describe Rack::Attack::StoreProxy::DalliProxy do
|
||||
|
||||
it 'should stub Dalli::Client#with on older clients' do
|
||||
proxy = Rack::Attack::StoreProxy::DalliProxy.new(Class.new)
|
||||
proxy.with {} # will not raise an error
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
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'
|
||||
|
||||
describe Rack::Attack::PathNormalizer do
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
|
||||
describe 'Rack::Attack' do
|
||||
|
|
@ -14,6 +16,6 @@ describe 'Rack::Attack' do
|
|||
end
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
|
||||
describe 'Rack::Attack' do
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
describe 'normalizing paths' do
|
||||
before do
|
||||
Rack::Attack.blocklist("banned_path") {|req| req.path == '/foo' }
|
||||
Rack::Attack.blocklist("banned_path") { |req| req.path == '/foo' }
|
||||
end
|
||||
|
||||
it 'blocks requests with trailing slash' do
|
||||
|
|
@ -17,20 +19,13 @@ describe 'Rack::Attack' do
|
|||
describe 'blocklist' do
|
||||
before do
|
||||
@bad_ip = '1.2.3.4'
|
||||
Rack::Attack.blocklist("ip #{@bad_ip}") {|req| req.ip == @bad_ip }
|
||||
Rack::Attack.blocklist("ip #{@bad_ip}") { |req| req.ip == @bad_ip }
|
||||
end
|
||||
|
||||
it('has a blocklist') {
|
||||
Rack::Attack.blocklists.key?("ip #{@bad_ip}").must_equal true
|
||||
}
|
||||
|
||||
it('has a blacklist with a deprication warning') {
|
||||
_, stderror = capture_io do
|
||||
Rack::Attack.blacklists.key?("ip #{@bad_ip}").must_equal true
|
||||
end
|
||||
assert_match "[DEPRECATION] 'Rack::Attack.blacklists' is deprecated. Please use 'blocklists' instead.", stderror
|
||||
}
|
||||
|
||||
describe "a bad request" do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => @bad_ip }
|
||||
|
||||
|
|
@ -44,24 +39,17 @@ describe 'Rack::Attack' do
|
|||
last_request.env['rack.attack.match_type'].must_equal :blocklist
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
end
|
||||
|
||||
describe "and safelist" do
|
||||
before do
|
||||
@good_ua = 'GoodUA'
|
||||
Rack::Attack.safelist("good ua") {|req| req.user_agent == @good_ua }
|
||||
Rack::Attack.safelist("good ua") { |req| req.user_agent == @good_ua }
|
||||
end
|
||||
|
||||
it('has a safelist') { Rack::Attack.safelists.key?("good ua") }
|
||||
|
||||
it('has a whitelist with a deprication warning') {
|
||||
_, stderror = capture_io do
|
||||
Rack::Attack.whitelists.key?("good ua")
|
||||
end
|
||||
assert_match "[DEPRECATION] 'Rack::Attack.whitelists' is deprecated. Please use 'safelists' instead.", stderror
|
||||
}
|
||||
|
||||
describe "with a request match both safelist & blocklist" do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua }
|
||||
|
||||
|
|
@ -80,13 +68,6 @@ describe 'Rack::Attack' do
|
|||
it 'should exist' do
|
||||
Rack::Attack.blocklisted_response.must_respond_to :call
|
||||
end
|
||||
|
||||
it 'should give a deprication warning for blacklisted_response' do
|
||||
_, stderror = capture_io do
|
||||
Rack::Attack.blacklisted_response
|
||||
end
|
||||
assert_match "[DEPRECATION] 'Rack::Attack.blacklisted_response' is deprecated. Please use 'blocklisted_response' instead.", stderror
|
||||
end
|
||||
end
|
||||
|
||||
describe '#throttled_response' do
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
|
||||
describe 'Rack::Attack.throttle' do
|
||||
|
|
@ -9,18 +11,18 @@ describe 'Rack::Attack.throttle' do
|
|||
|
||||
it('should have a throttle') { Rack::Attack.throttles.key?('ip/sec') }
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
describe 'a single request' do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
||||
|
||||
it 'should set the counter for one request' do
|
||||
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4"
|
||||
Rack::Attack.cache.store.read(key).must_equal 1
|
||||
end
|
||||
|
||||
it 'should populate throttle data' do
|
||||
data = { :count => 1, :limit => 1, :period => @period }
|
||||
data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i }
|
||||
last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data
|
||||
end
|
||||
end
|
||||
|
|
@ -37,7 +39,7 @@ describe 'Rack::Attack.throttle' do
|
|||
it 'should tag the env' do
|
||||
last_request.env['rack.attack.matched'].must_equal 'ip/sec'
|
||||
last_request.env['rack.attack.match_type'].must_equal :throttle
|
||||
last_request.env['rack.attack.match_data'].must_equal({:count => 2, :limit => 1, :period => @period})
|
||||
last_request.env['rack.attack.match_data'].must_equal(:count => 2, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i)
|
||||
last_request.env['rack.attack.match_discriminator'].must_equal('1.2.3.4')
|
||||
end
|
||||
|
||||
|
|
@ -51,21 +53,21 @@ describe 'Rack::Attack.throttle with limit as proc' do
|
|||
before do
|
||||
@period = 60 # Use a long period; failures due to cache key rotation less likely
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
Rack::Attack.throttle('ip/sec', :limit => lambda { |req| 1 }, :period => @period) { |req| req.ip }
|
||||
Rack::Attack.throttle('ip/sec', :limit => lambda { |_req| 1 }, :period => @period) { |req| req.ip }
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
describe 'a single request' do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
||||
|
||||
it 'should set the counter for one request' do
|
||||
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4"
|
||||
Rack::Attack.cache.store.read(key).must_equal 1
|
||||
end
|
||||
|
||||
it 'should populate throttle data' do
|
||||
data = { :count => 1, :limit => 1, :period => @period }
|
||||
data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i }
|
||||
last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data
|
||||
end
|
||||
end
|
||||
|
|
@ -75,21 +77,21 @@ describe 'Rack::Attack.throttle with period as proc' do
|
|||
before do
|
||||
@period = 60 # Use a long period; failures due to cache key rotation less likely
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
Rack::Attack.throttle('ip/sec', :limit => lambda { |req| 1 }, :period => lambda { |req| @period }) { |req| req.ip }
|
||||
Rack::Attack.throttle('ip/sec', :limit => lambda { |_req| 1 }, :period => lambda { |_req| @period }) { |req| req.ip }
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
describe 'a single request' do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
||||
|
||||
it 'should set the counter for one request' do
|
||||
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4"
|
||||
Rack::Attack.cache.store.read(key).must_equal 1
|
||||
end
|
||||
|
||||
it 'should populate throttle data' do
|
||||
data = { :count => 1, :limit => 1, :period => @period }
|
||||
data = { :count => 1, :limit => 1, :period => @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i }
|
||||
last_request.env['rack.attack.throttle_data']['ip/sec'].must_equal data
|
||||
end
|
||||
end
|
||||
|
|
@ -102,13 +104,13 @@ describe 'Rack::Attack.throttle with block retuning nil' do
|
|||
Rack::Attack.throttle('ip/sec', :limit => 1, :period => @period) { |_| nil }
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
describe 'a single request' do
|
||||
before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' }
|
||||
|
||||
it 'should not set the counter' do
|
||||
key = "rack::attack:#{Time.now.to_i/@period}:ip/sec:1.2.3.4"
|
||||
key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4"
|
||||
assert_nil Rack::Attack.cache.store.read(key)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'spec_helper'
|
||||
|
||||
describe 'Rack::Attack.track' do
|
||||
|
|
@ -16,10 +18,10 @@ describe 'Rack::Attack.track' do
|
|||
end
|
||||
|
||||
before do
|
||||
Rack::Attack.track("everything"){ |req| true }
|
||||
Rack::Attack.track("everything") { |_req| true }
|
||||
end
|
||||
|
||||
allow_ok_requests
|
||||
it_allows_ok_requests
|
||||
|
||||
it "should tag the env" do
|
||||
get '/'
|
||||
|
|
@ -31,9 +33,9 @@ describe 'Rack::Attack.track' do
|
|||
before do
|
||||
Counter.reset
|
||||
# A second track
|
||||
Rack::Attack.track("homepage"){ |req| req.path == "/"}
|
||||
Rack::Attack.track("homepage") { |req| req.path == "/" }
|
||||
|
||||
ActiveSupport::Notifications.subscribe("rack.attack") do |*args|
|
||||
ActiveSupport::Notifications.subscribe("rack.attack") do |*_args|
|
||||
Counter.incr
|
||||
end
|
||||
|
||||
|
|
@ -47,15 +49,15 @@ describe 'Rack::Attack.track' do
|
|||
|
||||
describe "without limit and period options" do
|
||||
it "should assign the track filter to a Check instance" do
|
||||
tracker = Rack::Attack.track("homepage") { |req| req.path == "/"}
|
||||
tracker.filter.class.must_equal Rack::Attack::Check
|
||||
track = Rack::Attack.track("homepage") { |req| req.path == "/" }
|
||||
track.filter.class.must_equal Rack::Attack::Check
|
||||
end
|
||||
end
|
||||
|
||||
describe "with limit and period options" do
|
||||
it "should assign the track filter to a Throttle instance" do
|
||||
tracker = Rack::Attack.track("homepage", :limit => 10, :period => 10) { |req| req.path == "/"}
|
||||
tracker.filter.class.must_equal Rack::Attack::Throttle
|
||||
track = Rack::Attack.track("homepage", :limit => 10, :period => 10) { |req| req.path == "/" }
|
||||
track.filter.class.must_equal Rack::Attack::Throttle
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
require "rubygems"
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "bundler/setup"
|
||||
|
||||
require "minitest/autorun"
|
||||
|
|
@ -9,26 +10,51 @@ require 'action_dispatch'
|
|||
|
||||
require "rack/attack"
|
||||
|
||||
begin
|
||||
require 'pry'
|
||||
rescue LoadError
|
||||
#nothing to do here
|
||||
if RUBY_ENGINE == "ruby"
|
||||
require "byebug"
|
||||
end
|
||||
|
||||
class MiniTest::Spec
|
||||
def safe_require(name)
|
||||
begin
|
||||
require name
|
||||
rescue LoadError
|
||||
end
|
||||
end
|
||||
|
||||
safe_require "connection_pool"
|
||||
safe_require "dalli"
|
||||
safe_require "redis"
|
||||
safe_require "redis-activesupport"
|
||||
safe_require "redis-store"
|
||||
|
||||
class MiniTest::Spec
|
||||
include Rack::Test::Methods
|
||||
|
||||
after { Rack::Attack.clear! }
|
||||
|
||||
def app
|
||||
Rack::Builder.new {
|
||||
use Rack::Attack
|
||||
run lambda {|env| [200, {}, ['Hello World']]}
|
||||
}.to_app
|
||||
before do
|
||||
@_original_throttled_response = Rack::Attack.throttled_response
|
||||
@_original_blocklisted_response = Rack::Attack.blocklisted_response
|
||||
end
|
||||
|
||||
def self.allow_ok_requests
|
||||
after do
|
||||
Rack::Attack.clear_configuration
|
||||
Rack::Attack.instance_variable_set(:@cache, nil)
|
||||
|
||||
Rack::Attack.throttled_response = @_original_throttled_response
|
||||
Rack::Attack.blocklisted_response = @_original_blocklisted_response
|
||||
end
|
||||
|
||||
def app
|
||||
Rack::Builder.new do
|
||||
# Use Rack::Lint to test that rack-attack is complying with the rack spec
|
||||
use Rack::Lint
|
||||
use Rack::Attack
|
||||
use Rack::Lint
|
||||
|
||||
run lambda { |_env| [200, {}, ['Hello World']] }
|
||||
end.to_app
|
||||
end
|
||||
|
||||
def self.it_allows_ok_requests
|
||||
it "must allow ok requests" do
|
||||
get '/', {}, 'REMOTE_ADDR' => '127.0.0.1'
|
||||
last_response.status.must_equal 200
|
||||
|
|
|
|||
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