Merge branch 'master' into support-redis-gem

This commit is contained in:
Gonzalo Rodriguez 2018-06-29 15:38:52 -03:00
commit 6fbb6c8b1c
No known key found for this signature in database
GPG key ID: 5DB8B81B049B8AB1
83 changed files with 2667 additions and 713 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ bin
*.gemfile.lock
.ruby-version
.ruby-gemset
.byebug_history

58
.rubocop.yml Normal file
View 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

View file

@ -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

View file

@ -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

View file

@ -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/

View file

@ -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
View 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.

View file

@ -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

View file

@ -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
View file

@ -1,63 +1,340 @@
# Rack::Attack!!!
# Rack::Attack
*Rack middleware for blocking & throttling abusive requests*
Rack::Attack is a rack middleware to protect your web app from bad clients.
It allows *safelisting*, *blocklisting*, *throttling*, and *tracking* based on arbitrary properties of the request.
Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to *allow*, *block* and *throttle* based on properties of the request.
Throttle and fail2ban state is stored in a configurable cache (e.g. `Rails.cache`), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)).
See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack.
See the [Backing & Hacking blog post](http://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack.
[![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](http://badge.fury.io/rb/rack-attack)
[![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](https://badge.fury.io/rb/rack-attack)
[![Build Status](https://travis-ci.org/kickstarter/rack-attack.svg?branch=master)](https://travis-ci.org/kickstarter/rack-attack)
[![Code Climate](https://codeclimate.com/github/kickstarter/rack-attack.svg)](https://codeclimate.com/github/kickstarter/rack-attack)
## Looking for maintainers
I'm looking for new maintainers to help me support Rack::Attack. Check out
[issue #219 for details](https://github.com/kickstarter/rack-attack/issues/219).
## Getting started
Install the [rack-attack](http://rubygems.org/gems/rack-attack) gem; or add it to your Gemfile with bundler:
### 1. Installing
Add this line to your application's Gemfile:
```ruby
# In your Gemfile
gem 'rack-attack'
```
Tell your app to use the Rack::Attack middleware.
For Rails apps:
And then execute:
$ bundle
Or install it yourself as:
$ gem install rack-attack
### 2. Plugging into the application
Then tell your ruby web application to use rack-attack as a middleware.
a) For __rails__ applications:
```ruby
# In config/application.rb
config.middleware.use Rack::Attack
```
Or for Rackup files:
b) For __rack__ applications:
```ruby
# In config.ru
require "rack/attack"
use Rack::Attack
```
Add a `rack-attack.rb` file to `config/initializers/`:
```ruby
# In config/initializers/rack-attack.rb
class Rack::Attack
# your custom configuration...
end
```
__IMPORTANT__: By default, rack-attack won't perform any blocking or throttling, until you specifically tell it what to protect against by configuring some rules.
## Usage
*Tip:* The example in the wiki is a great way to get started:
[Example Configuration](https://github.com/kickstarter/rack-attack/wiki/Example-Configuration)
Optionally configure the cache store for throttling or fail2ban filtering:
Define rules by calling `Rack::Attack` public methods, in any file that runs when your application is being initialized. For rails applications this means creating a new file named `config/initializers/rack_attack.rb` and writing your rules there.
### Safelisting
Safelists have the most precedence, so any request matching a safelist would be allowed despite matching any number of blocklists or throttles.
#### `safelist_ip(ip_address_string)`
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails app)
Rack::Attack.safelist_ip("5.6.7.8")
```
#### `safelist_ip(ip_subnet_string)`
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails app)
Rack::Attack.safelist_ip("5.6.7.0/24")
```
#### `safelist(name, &block)`
Name your custom safelist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise.
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails apps)
# Provided that trusted users use an HTTP request header named APIKey
Rack::Attack.safelist("mark any authenticated access safe") do |request|
# Requests are allowed if the return value is truthy
request.env["APIKey"] == "secret-string"
end
# Always allow requests from localhost
# (blocklist & throttles are skipped)
Rack::Attack.safelist('allow from localhost') do |req|
# Requests are allowed if the return value is truthy
'127.0.0.1' == req.ip || '::1' == req.ip
end
```
### Blocking
#### `blocklist_ip(ip_address_string)`
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails apps)
Rack::Attack.blocklist_ip("1.2.3.4")
```
#### `blocklist_ip(ip_subnet_string)`
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails apps)
Rack::Attack.blocklist_ip("1.2.0.0/16")
```
#### `blocklist(name, &block)`
Name your custom blocklist and make your ruby-block argument return a truthy value if you want the request to be blocked, and falsy otherwise.
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails apps)
Rack::Attack.blocklist("block all access to admin") do |request|
# Requests are blocked if the return value is truthy
request.path.start_with?("/admin")
end
Rack::Attack.blocklist('block bad UA logins') do |req|
req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end
```
#### Fail2Ban
`Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients.
This pattern is inspired by [fail2ban](https://www.fail2ban.org/wiki/index.php/Main_Page).
See the [fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on
how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter.
Fail2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
```ruby
# Block suspicious requests for '/etc/password' or wordpress specific paths.
# After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes.
Rack::Attack.blocklist('fail2ban pentesters') do |req|
# `filter` returns truthy value if request fails, or if it's from a previously banned IP
# so the request is blocked
Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do
# The count for the IP is incremented if the return value is truthy
CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
req.path.include?('/etc/passwd') ||
req.path.include?('wp-admin') ||
req.path.include?('wp-login')
end
end
```
Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`.
#### Allow2Ban
`Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving
clients until such time as they reach maxretry at which they are cut off as per normal.
Allow2ban state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
```ruby
# Lockout IP addresses that are hammering your login page.
# After 20 requests in 1 minute, block all requests from that IP for 1 hour.
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
# `filter` returns false value if request is to your login page (but still
# increments the count) so request below the limit are not blocked until
# they hit the limit. At that point, filter will return true and block.
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
# The count for the IP is incremented if the return value is truthy.
req.path == '/login' and req.post?
end
end
```
### Throttling
Throttle state is stored in a [configurable cache](#cache-store-configuration) (which defaults to `Rails.cache` if present).
#### `throttle(name, options, &block)`
Name your custom throttle, provide `limit` and `period` as options, and make your ruby-block argument return the __discriminator__. This discriminator is how you tell rack-attack whether you're limiting per IP address, per user email or any other.
The request object is a [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request).
E.g.
```ruby
# config/initializers/rack_attack.rb (for rails apps)
Rack::Attack.throttle("requests by ip", limit: 5, period: 2) do |request|
request.ip
end
# Throttle login attempts for a given email parameter to 6 reqs/minute
# Return the email as a discriminator on POST /login requests
Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req|
if req.path == '/login' && req.post?
req.params['email']
end
end
# You can also set a limit and period using a proc. For instance, after
# Rack::Auth::Basic has authenticated the user:
limit_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 100 : 1 }
period_proc = proc { |req| req.env["REMOTE_USER"] == "admin" ? 1 : 60 }
Rack::Attack.throttle('request per ip', limit: limit_proc, period: period_proc) do |request|
request.ip
end
```
### Tracks
```ruby
# Track requests from a special user agent.
Rack::Attack.track("special_agent") do |req|
req.user_agent == "SpecialAgent"
end
# Supports optional limit and period, triggers the notification only when the limit is reached.
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
req.user_agent == "SpecialAgent"
end
# Track it using ActiveSupport::Notification
ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, payload|
req = payload[:request]
if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
Rails.logger.info "special_agent: #{req.path}"
STATSD.increment("special_agent")
end
end
```
### Cache store configuration
Throttle, allow2ban and fail2ban state is stored in a configurable cache (which defaults to `Rails.cache` if present), presumably backed by memcached or redis ([at least gem v3.0.0](https://rubygems.org/gems/redis)).
```ruby
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache
```
Note that `Rack::Attack.cache` is only used for throttling and fail2ban filtering; not blocklisting & safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html).
Note that `Rack::Attack.cache` is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html).
## Customizing responses
Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC).
```ruby
Rack::Attack.blocklisted_response = lambda do |env|
# Using 503 because it may make attacker think that they have successfully
# DOSed the site. Rack::Attack returns 403 for blocklists by default
[ 503, {}, ['Blocked']]
end
Rack::Attack.throttled_response = lambda do |env|
# NB: you have access to the name and other data about the matched throttle
# env['rack.attack.matched'],
# env['rack.attack.match_type'],
# env['rack.attack.match_data'],
# env['rack.attack.match_discriminator']
# Using 503 because it may make attacker think that they have successfully
# DOSed the site. Rack::Attack returns 429 for throttling by default
[ 503, {}, ["Server Error\n"]]
end
```
### X-RateLimit headers for well-behaved clients
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
can also be used to return rate limit data that's helpful for well-behaved clients.
Here's an example response that includes conventional `X-RateLimit-*` headers:
```ruby
Rack::Attack.throttled_response = lambda do |env|
match_data = env['rack.attack.match_data']
now = match_data[:epoch_time]
headers = {
'X-RateLimit-Limit' => match_data[:limit].to_s,
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s
}
[ 429, headers, ["Throttled\n"]]
end
```
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
```ruby
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l, :epoch_time => t }
```
## Logging & Instrumentation
Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available.
You can subscribe to 'rack.attack' events and log it, graph it, etc:
```ruby
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, payload|
puts payload[:request].inspect
end
```
## How it works
@ -91,200 +368,10 @@ Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you
can cleanly monkey patch helper methods onto the
[request object](https://github.com/kickstarter/rack-attack/blob/master/lib/rack/attack/request.rb).
## About Tracks
### About Tracks
`Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes.
## Usage
Define safelists, blocklists, throttles, and tracks as blocks that return truthy values if matched, falsy otherwise. In a Rails app
these go in an initializer in `config/initializers/`.
A [Rack::Request](http://www.rubydoc.info/gems/rack/Rack/Request) object is passed to the block (named 'req' in the examples).
### Safelists
```ruby
# Always allow requests from localhost
# (blocklist & throttles are skipped)
Rack::Attack.safelist('allow from localhost') do |req|
# Requests are allowed if the return value is truthy
'127.0.0.1' == req.ip || '::1' == req.ip
end
```
### Blocklists
```ruby
# Block requests from 1.2.3.4
Rack::Attack.blocklist('block 1.2.3.4') do |req|
# Requests are blocked if the return value is truthy
'1.2.3.4' == req.ip
end
# Block logins from a bad user agent
Rack::Attack.blocklist('block bad UA logins') do |req|
req.path == '/login' && req.post? && req.user_agent == 'BadUA'
end
```
#### Fail2Ban
`Fail2Ban.filter` can be used within a blocklist to block all requests from misbehaving clients.
This pattern is inspired by [fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page).
See the [fail2ban documentation](http://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jail_Options) for more details on
how the parameters work. For multiple filters, be sure to put each filter in a separate blocklist and use a unique discriminator for each fail2ban filter.
```ruby
# Block suspicious requests for '/etc/password' or wordpress specific paths.
# After 3 blocked requests in 10 minutes, block all requests from that IP for 5 minutes.
Rack::Attack.blocklist('fail2ban pentesters') do |req|
# `filter` returns truthy value if request fails, or if it's from a previously banned IP
# so the request is blocked
Rack::Attack::Fail2Ban.filter("pentesters-#{req.ip}", maxretry: 3, findtime: 10.minutes, bantime: 5.minutes) do
# The count for the IP is incremented if the return value is truthy
CGI.unescape(req.query_string) =~ %r{/etc/passwd} ||
req.path.include?('/etc/passwd') ||
req.path.include?('wp-admin') ||
req.path.include?('wp-login')
end
end
```
Note that `Fail2Ban` filters are not automatically scoped to the blocklist, so when using multiple filters in an application the scoping must be added to the discriminator e.g. `"pentest:#{req.ip}"`.
#### Allow2Ban
`Allow2Ban.filter` works the same way as the `Fail2Ban.filter` except that it *allows* requests from misbehaving
clients until such time as they reach maxretry at which they are cut off as per normal.
```ruby
# Lockout IP addresses that are hammering your login page.
# After 20 requests in 1 minute, block all requests from that IP for 1 hour.
Rack::Attack.blocklist('allow2ban login scrapers') do |req|
# `filter` returns false value if request is to your login page (but still
# increments the count) so request below the limit are not blocked until
# they hit the limit. At that point, filter will return true and block.
Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 20, findtime: 1.minute, bantime: 1.hour) do
# The count for the IP is incremented if the return value is truthy.
req.path == '/login' and req.post?
end
end
```
### Throttles
```ruby
# Throttle requests to 5 requests per second per ip
Rack::Attack.throttle('req/ip', limit: 5, period: 1.second) do |req|
# If the return value is truthy, the cache key for the return value
# is incremented and compared with the limit. In this case:
# "rack::attack:#{Time.now.to_i/1.second}:req/ip:#{req.ip}"
#
# If falsy, the cache key is neither incremented nor checked.
req.ip
end
# Throttle login attempts for a given email parameter to 6 reqs/minute
# Return the email as a discriminator on POST /login requests
Rack::Attack.throttle('logins/email', limit: 6, period: 60) do |req|
req.params['email'] if req.path == '/login' && req.post?
end
# You can also set a limit and period using a proc. For instance, after
# Rack::Auth::Basic has authenticated the user:
limit_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 100 : 1}
period_proc = proc {|req| req.env["REMOTE_USER"] == "admin" ? 1.second : 1.minute}
Rack::Attack.throttle('req/ip', limit: limit_proc, period: period_proc) do |req|
req.ip
end
```
### Tracks
```ruby
# Track requests from a special user agent.
Rack::Attack.track("special_agent") do |req|
req.user_agent == "SpecialAgent"
end
# Supports optional limit and period, triggers the notification only when the limit is reached.
Rack::Attack.track("special_agent", limit: 6, period: 60) do |req|
req.user_agent == "SpecialAgent"
end
# Track it using ActiveSupport::Notification
ActiveSupport::Notifications.subscribe("rack.attack") do |name, start, finish, request_id, req|
if req.env['rack.attack.matched'] == "special_agent" && req.env['rack.attack.match_type'] == :track
Rails.logger.info "special_agent: #{req.path}"
STATSD.increment("special_agent")
end
end
```
## Responses
Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://rack.rubyforge.org/doc/SPEC.html).
```ruby
Rack::Attack.blocklisted_response = lambda do |env|
# Using 503 because it may make attacker think that they have successfully
# DOSed the site. Rack::Attack returns 403 for blocklists by default
[ 503, {}, ['Blocked']]
end
Rack::Attack.throttled_response = lambda do |env|
# NB: you have access to the name and other data about the matched throttle
# env['rack.attack.matched'],
# env['rack.attack.match_type'],
# env['rack.attack.match_data']
# Using 503 because it may make attacker think that they have successfully
# DOSed the site. Rack::Attack returns 429 for throttling by default
[ 503, {}, ["Server Error\n"]]
end
```
### X-RateLimit headers for well-behaved clients
While Rack::Attack's primary focus is minimizing harm from abusive clients, it
can also be used to return rate limit data that's helpful for well-behaved clients.
Here's an example response that includes conventional `X-RateLimit-*` headers:
```ruby
Rack::Attack.throttled_response = lambda do |env|
now = Time.now
match_data = env['rack.attack.match_data']
headers = {
'X-RateLimit-Limit' => match_data[:limit].to_s,
'X-RateLimit-Remaining' => '0',
'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).to_s
}
[ 429, headers, ["Throttled\n"]]
end
```
For responses that did not exceed a throttle limit, Rack::Attack annotates the env with match data:
```ruby
request.env['rack.attack.throttle_data'][name] # => { :count => n, :period => p, :limit => l }
```
## Logging & Instrumentation
Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available.
You can subscribe to 'rack.attack' events and log it, graph it, etc:
```ruby
ActiveSupport::Notifications.subscribe('rack.attack') do |name, start, finish, request_id, req|
puts req.inspect
end
```
## Testing
@ -302,7 +389,7 @@ so try to keep the number of throttle checks per request low.
If a request is blocklisted or throttled, the response is a very simple Rack response.
A single typical ruby web server thread can block several hundred requests per second.
Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone).
Rack::Attack complements tools like `iptables` and nginx's [limit_conn_zone module](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone).
## Motivation
@ -316,26 +403,15 @@ less on short-term, one-off hacks to block a particular attack.
## Contributing
Pull requests and issues are greatly appreciated. This project is intended to be
a safe, welcoming space for collaboration, and contributors are expected to
adhere to the [Code of Conduct](CODE_OF_CONDUCT.md).
Check out the [Contributing guide](CONTRIBUTING.md).
### Testing pull requests
## Code of Conduct
To run the minitest test suite, you will need both [Redis](http://redis.io/) and
[Memcached](https://memcached.org/) running locally and bound to IP `127.0.0.1` on
default ports (`6379` for Redis, and `11211` for Memcached) and able to be
accessed without authentication.
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Code of Conduct](CODE_OF_CONDUCT.md).
Install dependencies by running
```sh
bundle install
```
## Development setup
Then run the test suite by running
```sh
bundle exec rake
```
Check out the [Development guide](docs/development.md).
## Mailing list
@ -348,4 +424,4 @@ New releases of Rack::Attack are announced on
Copyright Kickstarter, PBC.
Released under an [MIT License](http://opensource.org/licenses/MIT).
Released under an [MIT License](https://opensource.org/licenses/MIT).

View file

@ -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
View 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

View file

@ -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

View 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: "../"

View 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: "../"

View 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: "../"

View 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: "../"

View file

@ -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
View 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: "../"

View 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: "../"

View file

@ -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: "../"

View file

@ -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: "../"

View file

@ -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: "../"

View 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: "../"

View 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: "../"

View file

@ -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

View file

@ -1,8 +1,11 @@
# frozen_string_literal: true
module Rack
class Attack
class Allow2Ban < Fail2Ban
class << self
protected
def key_prefix
'allow2ban'
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
module Rack
class Attack
VERSION = '5.0.1'
VERSION = '5.3.2'
end
end

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_relative 'spec_helper'
describe Rack::Attack::PathNormalizer do

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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