rack-attack-6.3.1/0000755000004100000410000000000013661722201013736 5ustar www-datawww-datarack-attack-6.3.1/README.md0000644000004100000410000004231113661722201015216 0ustar www-datawww-data__Note__: You are viewing the development version README. For the README consistent with the latest released version see https://github.com/kickstarter/rack-attack/blob/6-stable/README.md. # Rack::Attack *Rack middleware for blocking & throttling abusive requests* Protect your Rails and Rack apps from bad clients. Rack::Attack lets you easily decide when to *allow*, *block* and *throttle* based on properties of the request. See the [Backing & Hacking blog post](https://www.kickstarter.com/backing-and-hacking/rack-attack-protection-from-abusive-clients) introducing Rack::Attack. [![Gem Version](https://badge.fury.io/rb/rack-attack.svg)](https://badge.fury.io/rb/rack-attack) [![Build 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) [![Join the chat at https://gitter.im/rack-attack/rack-attack](https://badges.gitter.im/rack-attack/rack-attack.svg)](https://gitter.im/rack-attack/rack-attack) ## Table of contents - [Getting started](#getting-started) - [Installing](#installing) - [Plugging into the application](#plugging-into-the-application) - [Usage](#usage) - [Safelisting](#safelisting) - [`safelist_ip(ip_address_string)`](#safelist_ipip_address_string) - [`safelist_ip(ip_subnet_string)`](#safelist_ipip_subnet_string) - [`safelist(name, &block)`](#safelistname-block) - [Blocking](#blocking) - [`blocklist_ip(ip_address_string)`](#blocklist_ipip_address_string) - [`blocklist_ip(ip_subnet_string)`](#blocklist_ipip_subnet_string) - [`blocklist(name, &block)`](#blocklistname-block) - [Fail2Ban](#fail2ban) - [Allow2Ban](#allow2ban) - [Throttling](#throttling) - [`throttle(name, options, &block)`](#throttlename-options-block) - [Tracks](#tracks) - [Cache store configuration](#cache-store-configuration) - [Customizing responses](#customizing-responses) - [RateLimit headers for well-behaved clients](#ratelimit-headers-for-well-behaved-clients) - [Logging & Instrumentation](#logging--instrumentation) - [Testing](#testing) - [How it works](#how-it-works) - [About Tracks](#about-tracks) - [Testing](#testing) - [Performance](#performance) - [Motivation](#motivation) - [Contributing](#contributing) - [Code of Conduct](#code-of-conduct) - [Development setup](#development-setup) - [License](#license) ## Getting started ### Installing Add this line to your application's Gemfile: ```ruby # In your Gemfile gem 'rack-attack' ``` And then execute: $ bundle Or install it yourself as: $ gem install rack-attack ### Plugging into the application Then tell your ruby web application to use rack-attack as a middleware. a) For __rails__ applications with versions >= 5.1 it is used by default. For older rails versions you should enable it explicitly: ```ruby # In config/application.rb config.middleware.use Rack::Attack ``` You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing: ```ruby Rack::Attack.enabled = false ``` b) For __rack__ applications: ```ruby # In config.ru require "rack/attack" use Rack::Attack ``` __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:* If you just want to get going asap, then you can take our [example configuration](docs/example_configuration.md) and tailor it to your needs, or check out the [advanced configuration](docs/advanced_configuration.md) examples. 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 allowed, 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("track.rack_attack") do |name, start, finish, request_id, payload| req = payload[:request] if req.env['rack.attack.matched'] == "special_agent" Rails.logger.info "special_agent: #{req.path}" STATSD.increment("special_agent") end end ``` ### 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, 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 ``` ### 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. If you want to return to user how many seconds to wait until he can start sending requests again, this can be done through enabling `Retry-After` header: ```ruby Rack::Attack.throttled_response_retry_after_header = true ``` Here's an example response that includes conventional `RateLimit-*` headers: ```ruby Rack::Attack.throttled_response = lambda do |env| match_data = env['rack.attack.match_data'] now = match_data[:epoch_time] headers = { 'RateLimit-Limit' => match_data[:limit].to_s, 'RateLimit-Remaining' => '0', 'RateLimit-Reset' => (now + (match_data[:period] - now % match_data[:period])).to_s } [ 429, headers, ["Throttled\n"]] 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] # => { discriminator: d, count: n, period: p, limit: l, epoch_time: t } ``` ## Logging & Instrumentation Rack::Attack uses the [ActiveSupport::Notifications](http://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) API if available. You can subscribe to `rack_attack` events and log it, graph it, etc. To get notified about specific type of events, subscribe to the event name followed by the `rack_attack` namesapce. E.g. for throttles use: ```ruby ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload| # request object available in payload[:request] # Your code here end ``` If you want to subscribe to every `rack_attack` event, use: ```ruby ActiveSupport::Notifications.subscribe(/rack_attack/) do |name, start, finish, request_id, payload| # request object available in payload[:request] # Your code here end ``` ## Testing ### Disabling `Rack::Attack.enabled = false` can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only. ### Test case isolation `Rack::Attack.reset!` can be used in your test suite to clear any Rack::Attack state between different test cases. ## How it works The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. * If the request matches any **safelist**, it is allowed. * Otherwise, if the request matches any **blocklist**, it is blocked. * Otherwise, if the request matches any **throttle**, a counter is incremented in the Rack::Attack.cache. If any throttle's limit is exceeded, the request is blocked. * Otherwise, all **tracks** are checked, and the request is allowed. The algorithm is actually more concise in code: See [Rack::Attack.call](lib/rack/attack.rb): ```ruby def call(env) req = Rack::Attack::Request.new(env) if safelisted?(req) @app.call(env) elsif blocklisted?(req) self.class.blocklisted_response.call(env) elsif throttled?(req) self.class.throttled_response.call(env) else tracked?(req) @app.call(env) end end ``` Note: `Rack::Attack::Request` is just a subclass of `Rack::Request` so that you can cleanly monkey patch helper methods onto the [request object](lib/rack/attack/request.rb). ### About Tracks `Rack::Attack.track` doesn't affect request processing. Tracks are an easy way to log and measure requests matching arbitrary attributes. ## Testing A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html) for more on how to do this. ## Performance The overhead of running Rack::Attack is typically negligible (a few milliseconds per request), but it depends on how many checks you've configured, and how long they take. Throttles usually require a network roundtrip to your cache server(s), 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](https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html#limit_conn_zone). ## Motivation Abusive clients range from malicious login crackers to naively-written scrapers. They hinder the security, performance, & availability of web applications. It is impractical if not impossible to block abusive clients completely. Rack::Attack aims to let developers quickly mitigate abusive requests and rely less on short-term, one-off hacks to block a particular attack. ## Contributing Check out the [Contributing guide](CONTRIBUTING.md). ## Code of Conduct 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). ## Development setup Check out the [Development guide](docs/development.md). ## License Copyright Kickstarter, PBC. Released under an [MIT License](https://opensource.org/licenses/MIT). rack-attack-6.3.1/bin/0000755000004100000410000000000013661722201014506 5ustar www-datawww-datarack-attack-6.3.1/bin/setup0000755000004100000410000000020313661722201015567 0ustar www-datawww-data#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here rack-attack-6.3.1/spec/0000755000004100000410000000000013661722201014670 5ustar www-datawww-datarack-attack-6.3.1/spec/allow2ban_spec.rb0000644000004100000410000000674013661722201020117 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.Allow2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @cache = Rack::Attack.cache @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @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/ } end end describe 'discriminator has not been banned' do describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' _(last_response.status).must_equal 200 end end describe 'making qualifying request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'succeeds' do _(last_response.status).must_equal 200 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:allow2ban:1.2.3.4" _(@cache.store.read(key)).must_be_nil end end describe 'when at maxretry' do before do # maxretry is 2 - so hit with an extra failed request first get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'succeeds' do _(last_response.status).must_equal 200 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end describe 'discriminator has been banned' do before do # maxretry is 2 - so hit enough times to get banned get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' _(last_response.status).must_equal 200 end end describe 'making ok request' do before do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'making failing request' do before do get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:allow2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:allow2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end rack-attack-6.3.1/spec/rack_attack_throttle_spec.rb0000644000004100000410000001033713661722201022427 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.throttle' 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 it('should have a throttle') { Rack::Attack.throttles.key?('ip/sec') } it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end it 'should populate throttle data' do data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end describe "with 2 requests" do before do 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } end it 'should block the last request' do _(last_response.status).must_equal 429 end it 'should tag the env' do _(last_request.env['rack.attack.matched']).must_equal 'ip/sec' _(last_request.env['rack.attack.match_type']).must_equal :throttle _(last_request.env['rack.attack.match_data']).must_equal( count: 2, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" ) _(last_request.env['rack.attack.match_discriminator']).must_equal('1.2.3.4') end end end describe 'Rack::Attack.throttle with limit as proc' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: lambda { |_req| 1 }, period: @period) { |req| req.ip } end it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end it 'should populate throttle data' do data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end describe 'Rack::Attack.throttle with period as proc' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: lambda { |_req| 1 }, period: lambda { |_req| @period }) { |req| req.ip } end it_allows_ok_requests describe 'a single request' do before { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'should set the counter for one request' do key = "rack::attack:#{Time.now.to_i / @period}:ip/sec:1.2.3.4" _(Rack::Attack.cache.store.read(key)).must_equal 1 end it 'should populate throttle data' do data = { count: 1, limit: 1, period: @period, epoch_time: Rack::Attack.cache.last_epoch_time.to_i, discriminator: "1.2.3.4" } _(last_request.env['rack.attack.throttle_data']['ip/sec']).must_equal data end end end describe 'Rack::Attack.throttle with block retuning nil' do before do @period = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |_| nil } end 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" assert_nil Rack::Attack.cache.store.read(key) end it 'should not populate throttle data' do assert_nil last_request.env['rack.attack.throttle_data'] end end end rack-attack-6.3.1/spec/rack_attack_path_normalizer_spec.rb0000644000004100000410000000072313661722201023756 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe Rack::Attack::PathNormalizer do subject { Rack::Attack::PathNormalizer } it 'should have a normalize_path method' do _(subject.normalize_path('/foo')).must_equal '/foo' end describe 'FallbackNormalizer' do subject { Rack::Attack::FallbackPathNormalizer } it '#normalize_path does not change the path' do _(subject.normalize_path('')).must_equal '' end end end rack-attack-6.3.1/spec/acceptance/0000755000004100000410000000000013661722201016756 5ustar www-datawww-datarack-attack-6.3.1/spec/acceptance/cache_store_config_for_allow2ban_spec.rb0000644000004100000410000000611513661722201026733 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "minitest/stub_const" describe "Cache store config when using allow2ban" do before do Rack::Attack.blocklist("allow2ban pentesters") do |request| Rack::Attack::Allow2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("scarce-resource") end end end 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 raised_exception = nil fake_store_class = Class.new do def write(key, value); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message end it "gives semantic error if store is missing #write method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message end it "gives semantic error if store is missing #increment method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def write(key, value); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/scarce-resource" end end assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message end it "works with any object that responds to #read, #write and #increment" do fake_store_class = Class.new do attr_accessor :backend def initialize @backend = {} end def read(key) @backend[key] end def write(key, value, _options = {}) @backend[key] = value end def increment(key, _count, _options = {}) @backend[key] ||= 0 @backend[key] += 1 end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new get "/" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 200, last_response.status get "/scarce-resource" assert_equal 403, last_response.status get "/" assert_equal 403, last_response.status end end end rack-attack-6.3.1/spec/acceptance/throttling_spec.rb0000644000004100000410000001223113661722201022512 0ustar www-datawww-data# 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_nil last_response.headers["Retry-After"] assert_equal "Retry later\n", last_response.body get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status Timecop.travel(60) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end end it "returns correct Retry-After header if enabled" do Rack::Attack.throttled_response_retry_after_header = true Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end Timecop.freeze(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end Timecop.freeze(Time.at(25)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal "35", last_response.headers["Retry-After"] end end it "supports limit to be dynamic" do # Could be used to have different rate limits for authorized # vs general requests limit_proc = lambda do |request| if request.env["X-APIKey"] == "private-secret" 2 else 1 end end Rack::Attack.throttle("by ip", limit: limit_proc, period: 60) do |request| request.ip end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 429, last_response.status end it "supports period to be dynamic" do # Could be used to have different rate limits for authorized # vs general requests period_proc = lambda do |request| if request.env["X-APIKey"] == "private-secret" 10 else 30 end end Rack::Attack.throttle("by ip", limit: 1, period: period_proc) do |request| request.ip end # Using Time#at to align to start/end of periods exactly # to achieve consistenty in different test runs Timecop.travel(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end Timecop.travel(Time.at(10)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status end Timecop.travel(Time.at(30)) do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end Timecop.travel(Time.at(0)) do get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 429, last_response.status end Timecop.travel(Time.at(10)) do get "/", {}, "REMOTE_ADDR" => "5.6.7.8", "X-APIKey" => "private-secret" assert_equal 200, last_response.status end end it "notifies when the request is throttled" do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end notification_matched = nil notification_type = nil notification_data = nil notification_discriminator = nil ActiveSupport::Notifications.subscribe("throttle.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 rack-attack-6.3.1/spec/acceptance/allow2ban_spec.rb0000644000004100000410000000336313661722201022203 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/track_throttle_spec.rb0000644000004100000410000000270713661722201023354 0ustar www-datawww-data# 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("track.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 rack-attack-6.3.1/spec/acceptance/stores/0000755000004100000410000000000013661722201020275 5ustar www-datawww-datarack-attack-6.3.1/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb0000644000004100000410000000131113661722201031766 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" should_run = defined?(::ConnectionPool) && defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore) if should_run 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_redis_store_spec.rb0000644000004100000410000000101713661722201027304 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::ActiveSupport::Cache::RedisStore) require_relative "../../support/cache_store_helper" require "timecop" describe "ActiveSupport::Cache::RedisStore as a cache backend" do before do Rack::Attack.cache.store = ActiveSupport::Cache::RedisStore.new end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb0000644000004100000410000000105713661722201031445 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::ConnectionPool) && defined?(::Dalli) require_relative "../../support/cache_store_helper" require "timecop" describe "ActiveSupport::Cache::MemCacheStore (pooled) as a cache backend" do before do Rack::Attack.cache.store = ActiveSupport::Cache::MemCacheStore.new(pool_size: 2) end after do Rack::Attack.cache.store.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_redis_cache_store_spec.rb0000644000004100000410000000122213661722201030425 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" should_run = defined?(::Redis) && Gem::Version.new(::Redis::VERSION) >= Gem::Version.new("4") && defined?(::ActiveSupport::Cache::RedisCacheStore) if should_run require_relative "../../support/cache_store_helper" 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_dalli_store_spec.rb0000644000004100000410000000104213661722201027261 0ustar www-datawww-data# 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/redis_store_spec.rb0000644000004100000410000000075113661722201024161 0ustar www-datawww-data# 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/connection_pool_dalli_client_spec.rb0000644000004100000410000000121013661722201027521 0ustar www-datawww-data# 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( fetch_from_store: ->(key) { Rack::Attack.cache.store.with { |client| client.fetch(key) } } ) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_mem_cache_store_spec.rb0000644000004100000410000000077213661722201030106 0ustar www-datawww-data# 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.clear end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.read(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/dalli_client_spec.rb0000644000004100000410000000074513661722201024265 0ustar www-datawww-data# 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/redis_spec.rb0000644000004100000410000000070513661722201022744 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" if defined?(::Redis) require_relative "../../support/cache_store_helper" require "timecop" describe "Plain redis as a cache backend" do before do Rack::Attack.cache.store = Redis.new end after do Rack::Attack.cache.store.flushdb end it_works_for_cache_backed_features(fetch_from_store: ->(key) { Rack::Attack.cache.store.get(key) }) end end rack-attack-6.3.1/spec/acceptance/stores/active_support_memory_store_spec.rb0000644000004100000410000000071013661722201027505 0ustar www-datawww-data# 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(fetch_from_store: ->(key) { Rack::Attack.cache.store.fetch(key) }) end rack-attack-6.3.1/spec/acceptance/blocking_subnet_spec.rb0000644000004100000410000000212113661722201023461 0ustar www-datawww-data# 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("blocklist.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 rack-attack-6.3.1/spec/acceptance/customizing_blocked_response_spec.rb0000644000004100000410000000171113661722201026271 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/fail2ban_spec.rb0000644000004100000410000000351213661722201021774 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/cache_store_config_with_rails_spec.rb0000644000004100000410000000151413661722201026347 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/cache_store_config_for_throttle_spec.rb0000644000004100000410000000223713661722201026720 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/blocking_ip_spec.rb0000644000004100000410000000163413661722201022601 0ustar www-datawww-data# 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("blocklist.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 rack-attack-6.3.1/spec/acceptance/track_spec.rb0000644000004100000410000000145513661722201021426 0ustar www-datawww-data# 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("track.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 rack-attack-6.3.1/spec/acceptance/customizing_throttled_response_spec.rb0000644000004100000410000000306213661722201026700 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/blocking_spec.rb0000644000004100000410000000422513661722201022110 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "#blocklist" do before do Rack::Attack.blocklist do |request| request.ip == "1.2.3.4" end end it "forbids request if blocklist condition is true" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "notifies when the request is blocked" do 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_nil notification_matched assert_equal :blocklist, notification_type end end describe "#blocklist with name" 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("blocklist.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 rack-attack-6.3.1/spec/acceptance/extending_request_object_spec.rb0000644000004100000410000000137513661722201025406 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/acceptance/cache_store_config_for_fail2ban_spec.rb0000644000004100000410000000563113661722201026532 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" require "minitest/stub_const" describe "Cache store config when using fail2ban" do before do Rack::Attack.blocklist("fail2ban pentesters") do |request| Rack::Attack::Fail2Ban.filter(request.ip, maxretry: 2, findtime: 30, bantime: 60) do request.path.include?("private-place") end end end 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 raised_exception = nil fake_store_class = Class.new do def write(key, value); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #read method", raised_exception.message end it "gives semantic error if store is missing #write method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def increment(key, count, options = {}); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #write method", raised_exception.message end it "gives semantic error if store is missing #increment method" do raised_exception = nil fake_store_class = Class.new do def read(key); end def write(key, value); end end Object.stub_const(:FakeStore, fake_store_class) do Rack::Attack.cache.store = FakeStore.new raised_exception = assert_raises(Rack::Attack::MisconfiguredStoreError) do get "/private-place" end end assert_equal "Configured store FakeStore doesn't respond to #increment method", raised_exception.message end it "works with any object that responds to #read, #write and #increment" do FakeStore = 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 = FakeStore.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 rack-attack-6.3.1/spec/acceptance/safelisting_ip_spec.rb0000644000004100000410000000251513661722201023320 0ustar www-datawww-data# 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("safelist.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 rack-attack-6.3.1/spec/acceptance/safelisting_spec.rb0000644000004100000410000000576713661722201022644 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" describe "#safelist" do before do Rack::Attack.blocklist do |request| request.ip == "1.2.3.4" end Rack::Attack.safelist do |request| request.path == "/safe_space" end end it "forbids request if blocklist condition is true and safelist is false" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status end it "succeeds if blocklist condition is false and safelist is false" do get "/", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if blocklist condition is false and safelist is true" do get "/safe_space", {}, "REMOTE_ADDR" => "5.6.7.8" assert_equal 200, last_response.status end it "succeeds request if both blocklist and safelist conditions are true" do get "/safe_space", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status end it "notifies when the request is safe" do 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_nil notification_matched assert_equal :safelist, notification_type end end describe "#safelist with name" 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("safelist.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 rack-attack-6.3.1/spec/acceptance/safelisting_subnet_spec.rb0000644000004100000410000000253613661722201024213 0ustar www-datawww-data# 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("safelist.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 rack-attack-6.3.1/spec/acceptance/rails_middleware_spec.rb0000644000004100000410000000202413661722201023622 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" if defined?(Rails) describe "Middleware for Rails" do before do @app = Class.new(Rails::Application) do config.eager_load = false config.logger = Logger.new(nil) # avoid creating the log/ directory automatically config.cache_store = :null_store # avoid creating tmp/ directory for cache end end if Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new("5.1") it "is used by default" do @app.initialize! assert_equal 1, @app.middleware.count(Rack::Attack) end it "is not added when it was explicitly deleted" do @app.config.middleware.delete(Rack::Attack) @app.initialize! refute @app.middleware.include?(Rack::Attack) end end if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("5.1") it "is not used by default" do @app.initialize! assert_equal 0, @app.middleware.count(Rack::Attack) end end end end rack-attack-6.3.1/spec/rack_attack_instrumentation_spec.rb0000644000004100000410000000230313661722201024017 0ustar www-datawww-data# frozen_string_literal: true require_relative "spec_helper" # ActiveSupport::Subscribers added in ~> 4.0.2.0 if ActiveSupport::VERSION::MAJOR > 3 require_relative 'spec_helper' require 'active_support/subscriber' class CustomSubscriber < ActiveSupport::Subscriber @notification_count = 0 class << self attr_accessor :notification_count end def throttle(_event) self.class.notification_count += 1 end end describe 'Rack::Attack.instrument' do before do @period = 60 # Use a long period; failures due to cache key rotation less likely Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('ip/sec', limit: 1, period: @period) { |req| req.ip } end describe "with throttling" do before do ActiveSupport::Notifications.stub(:notifier, ActiveSupport::Notifications::Fanout.new) do CustomSubscriber.attach_to("rack_attack") 2.times { get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' } end end it 'should instrument without error' do _(last_response.status).must_equal 429 assert_equal 1, CustomSubscriber.notification_count end end end end rack-attack-6.3.1/spec/rack_attack_track_spec.rb0000644000004100000410000000271413661722201021666 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.track' do class Counter def self.incr @counter += 1 end def self.reset @counter = 0 end def self.check @counter end end before do Rack::Attack.track("everything") { |_req| true } end it_allows_ok_requests it "should tag the env" do get '/' _(last_request.env['rack.attack.matched']).must_equal 'everything' _(last_request.env['rack.attack.match_type']).must_equal :track end describe "with a notification subscriber and two tracks" do before do Counter.reset # A second track Rack::Attack.track("homepage") { |req| req.path == "/" } ActiveSupport::Notifications.subscribe("track.rack_attack") do |*_args| Counter.incr end get "/" end it "should notify twice" do _(Counter.check).must_equal 2 end end describe "without limit and period options" do it "should assign the track filter to a Check instance" do 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 track = Rack::Attack.track("homepage", limit: 10, period: 10) { |req| req.path == "/" } _(track.filter.class).must_equal Rack::Attack::Throttle end end end rack-attack-6.3.1/spec/fail2ban_spec.rb0000644000004100000410000001010713661722201017704 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack.Fail2Ban' do before do # Use a long findtime; failures due to cache key rotation less likely @cache = Rack::Attack.cache @findtime = 60 @bantime = 60 Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @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/ } end end describe 'discriminator has not been banned' do describe 'making ok request' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' _(last_response.status).must_equal 200 end end describe 'making failing request' do describe 'when not at maxretry' do before { get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' } it 'fails' do _(last_response.status).must_equal 403 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end it 'is not banned' do key = "rack::attack:fail2ban:1.2.3.4" _(@cache.store.read(key)).must_be_nil end end describe 'when at maxretry' do before do # maxretry is 2 - so hit with an extra failed request first get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'increases fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'reset after success' do before do get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' Rack::Attack::Fail2Ban.reset('1.2.3.4', @f2b_options) get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'succeeds' do _(last_response.status).must_equal 200 end it 'resets fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" assert_nil @cache.store.read(key) end it 'IP is not banned' do _(Rack::Attack::Fail2Ban.banned?('1.2.3.4')).must_equal false end end end end describe 'discriminator has been banned' do before do # maxretry is 2 - so hit enough times to get banned get '/?test=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end describe 'making request for other discriminator' do it 'succeeds' do get '/', {}, 'REMOTE_ADDR' => '2.2.3.4' _(last_response.status).must_equal 200 end end describe 'making ok request' do before do get '/', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end describe 'making failing request' do before do get '/?foo=OMGHAX', {}, 'REMOTE_ADDR' => '1.2.3.4' end it 'fails' do _(last_response.status).must_equal 403 end it 'does not increase fail count' do key = "rack::attack:#{Time.now.to_i / @findtime}:fail2ban:count:1.2.3.4" _(@cache.store.read(key)).must_equal 2 end it 'is still banned' do key = "rack::attack:fail2ban:ban:1.2.3.4" _(@cache.store.read(key)).must_equal 1 end end end end rack-attack-6.3.1/spec/rack_attack_spec.rb0000644000004100000410000000653513661722201020507 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack' do it_allows_ok_requests describe 'normalizing paths' do before do Rack::Attack.blocklist("banned_path") { |req| req.path == '/foo' } end it 'blocks requests with trailing slash' do get '/foo/' _(last_response.status).must_equal 403 end end describe 'blocklist' do before do @bad_ip = '1.2.3.4' Rack::Attack.blocklist("ip #{@bad_ip}") { |req| req.ip == @bad_ip } end it 'has a blocklist' do _(Rack::Attack.blocklists.key?("ip #{@bad_ip}")).must_equal true end describe "a bad request" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip } it "should return a blocklist response" do _(last_response.status).must_equal 403 _(last_response.body).must_equal "Forbidden\n" end it "should tag the env" do _(last_request.env['rack.attack.matched']).must_equal "ip #{@bad_ip}" _(last_request.env['rack.attack.match_type']).must_equal :blocklist end 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 } end it('has a safelist') { Rack::Attack.safelists.key?("good ua") } describe "with a request match both safelist & blocklist" do before { get '/', {}, 'REMOTE_ADDR' => @bad_ip, 'HTTP_USER_AGENT' => @good_ua } it "should allow safelists before blocklists" do _(last_response.status).must_equal 200 end it "should tag the env" do _(last_request.env['rack.attack.matched']).must_equal 'good ua' _(last_request.env['rack.attack.match_type']).must_equal :safelist end end end describe '#blocklisted_response' do it 'should exist' do _(Rack::Attack.blocklisted_response).must_respond_to :call end end describe '#throttled_response' do it 'should exist' do _(Rack::Attack.throttled_response).must_respond_to :call end end end describe 'enabled' do it 'should be enabled by default' do _(Rack::Attack.enabled).must_equal true end it 'should directly pass request when disabled' do bad_ip = '1.2.3.4' Rack::Attack.blocklist("ip #{bad_ip}") { |req| req.ip == bad_ip } get '/', {}, 'REMOTE_ADDR' => bad_ip _(last_response.status).must_equal 403 prev_enabled = Rack::Attack.enabled begin Rack::Attack.enabled = false get '/', {}, 'REMOTE_ADDR' => bad_ip _(last_response.status).must_equal 200 ensure Rack::Attack.enabled = prev_enabled end end end describe 'reset!' do it 'raises an error when is not supported by cache store' do Rack::Attack.cache.store = Class.new assert_raises(Rack::Attack::IncompatibleStoreError) do Rack::Attack.reset! end end if defined?(Redis) it 'should delete rack attack keys' do redis = Redis.new redis.set('key', 'value') redis.set("#{Rack::Attack.cache.prefix}::key", 'value') Rack::Attack.cache.store = redis Rack::Attack.reset! _(redis.get('key')).must_equal 'value' _(redis.get("#{Rack::Attack.cache.prefix}::key")).must_be_nil end end end end rack-attack-6.3.1/spec/spec_helper.rb0000644000004100000410000000237013661722201017510 0ustar www-datawww-data# frozen_string_literal: true require "bundler/setup" require "minitest/autorun" require "minitest/pride" require "rack/test" require "rails" require "rack/attack" if RUBY_ENGINE == "ruby" require "byebug" end def safe_require(name) require name rescue LoadError nil 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 before do Rails.cache = nil end after do Rack::Attack.clear_configuration Rack::Attack.instance_variable_set(:@cache, nil) end def app Rack::Builder.new do # Use Rack::Lint to test that rack-attack is complying with the rack spec use Rack::Lint # Intentionally added twice to test idempotence property use Rack::Attack use Rack::Attack use Rack::Lint 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 _(last_response.body).must_equal 'Hello World' end end end class Minitest::SharedExamples < Module include Minitest::Spec::DSL end rack-attack-6.3.1/spec/rack_attack_dalli_proxy_spec.rb0000644000004100000410000000044013661722201023102 0ustar www-datawww-data# 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 rack-attack-6.3.1/spec/support/0000755000004100000410000000000013661722201016404 5ustar www-datawww-datarack-attack-6.3.1/spec/support/cache_store_helper.rb0000644000004100000410000000423313661722201022551 0ustar www-datawww-data# frozen_string_literal: true class Minitest::Spec def self.it_works_for_cache_backed_features(options) fetch_from_store = options.fetch(:fetch_from_store) it "works for throttle" do Rack::Attack.throttle("by ip", limit: 1, period: 60) do |request| request.ip end 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 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 fetch_from_store.call(key) sleep 2.1 assert_nil fetch_from_store.call(key) end end end rack-attack-6.3.1/spec/integration/0000755000004100000410000000000013661722201017213 5ustar www-datawww-datarack-attack-6.3.1/spec/integration/offline_spec.rb0000644000004100000410000000412313661722201022174 0ustar www-datawww-data# frozen_string_literal: true require 'active_support/cache' require_relative '../spec_helper' OfflineExamples = Minitest::SharedExamples.new do it 'should write' do @cache.write('cache-test-key', 'foobar', 1) end it 'should read' do @cache.read('cache-test-key') end it 'should count' do @cache.count('cache-test-key', 1) end it 'should delete' do @cache.delete('cache-test-key') end end if defined?(::ActiveSupport::Cache::RedisStore) describe 'when Redis is offline' do include OfflineExamples before do @cache = Rack::Attack::Cache.new # Use presumably unused port for Redis client @cache.store = ActiveSupport::Cache::RedisStore.new(host: '127.0.0.1', port: 3333) end end end if defined?(Redis) && defined?(ActiveSupport::Cache::RedisCacheStore) && Redis::VERSION >= '4' describe 'when Redis is offline' do include OfflineExamples before do @cache = Rack::Attack::Cache.new # Use presumably unused port for Redis client @cache.store = ActiveSupport::Cache::RedisCacheStore.new(host: '127.0.0.1', port: 3333) end end end if defined?(::Dalli) describe 'when Memcached is offline' do include OfflineExamples before do Dalli.logger.level = Logger::FATAL @cache = Rack::Attack::Cache.new @cache.store = Dalli::Client.new('127.0.0.1:22122') end after do Dalli.logger.level = Logger::INFO end end end if defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) describe 'when Memcached is offline' do include OfflineExamples before do Dalli.logger.level = Logger::FATAL @cache = Rack::Attack::Cache.new @cache.store = ActiveSupport::Cache::MemCacheStore.new('127.0.0.1:22122') end after do Dalli.logger.level = Logger::INFO end end end if defined?(Redis) describe 'when Redis is offline' do include OfflineExamples before do @cache = Rack::Attack::Cache.new # Use presumably unused port for Redis client @cache.store = Redis.new(host: '127.0.0.1', port: 3333) end end end rack-attack-6.3.1/spec/rack_attack_request_spec.rb0000644000004100000410000000055113661722201022247 0ustar www-datawww-data# frozen_string_literal: true require_relative 'spec_helper' describe 'Rack::Attack' do describe 'helpers' do before do class Rack::Attack::Request def remote_ip ip end end Rack::Attack.safelist('valid IP') do |req| req.remote_ip == "127.0.0.1" end end it_allows_ok_requests end end rack-attack-6.3.1/rack-attack.gemspec0000644000004100000410000002176013661722201017476 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: rack-attack 6.3.1 ruby lib Gem::Specification.new do |s| s.name = "rack-attack".freeze s.version = "6.3.1" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 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" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["Aaron Suggs".freeze] s.date = "2020-05-21" s.description = "A rack middleware for throttling and blocking abusive requests".freeze s.email = "aaron@ktheory.com".freeze s.files = ["README.md".freeze, "Rakefile".freeze, "bin/setup".freeze, "lib/rack/attack.rb".freeze, "lib/rack/attack/allow2ban.rb".freeze, "lib/rack/attack/blocklist.rb".freeze, "lib/rack/attack/cache.rb".freeze, "lib/rack/attack/check.rb".freeze, "lib/rack/attack/configuration.rb".freeze, "lib/rack/attack/fail2ban.rb".freeze, "lib/rack/attack/path_normalizer.rb".freeze, "lib/rack/attack/railtie.rb".freeze, "lib/rack/attack/request.rb".freeze, "lib/rack/attack/safelist.rb".freeze, "lib/rack/attack/store_proxy.rb".freeze, "lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb".freeze, "lib/rack/attack/store_proxy/dalli_proxy.rb".freeze, "lib/rack/attack/store_proxy/mem_cache_store_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_cache_store_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_proxy.rb".freeze, "lib/rack/attack/store_proxy/redis_store_proxy.rb".freeze, "lib/rack/attack/throttle.rb".freeze, "lib/rack/attack/track.rb".freeze, "lib/rack/attack/version.rb".freeze, "spec/acceptance/allow2ban_spec.rb".freeze, "spec/acceptance/blocking_ip_spec.rb".freeze, "spec/acceptance/blocking_spec.rb".freeze, "spec/acceptance/blocking_subnet_spec.rb".freeze, "spec/acceptance/cache_store_config_for_allow2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_fail2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_throttle_spec.rb".freeze, "spec/acceptance/cache_store_config_with_rails_spec.rb".freeze, "spec/acceptance/customizing_blocked_response_spec.rb".freeze, "spec/acceptance/customizing_throttled_response_spec.rb".freeze, "spec/acceptance/extending_request_object_spec.rb".freeze, "spec/acceptance/fail2ban_spec.rb".freeze, "spec/acceptance/rails_middleware_spec.rb".freeze, "spec/acceptance/safelisting_ip_spec.rb".freeze, "spec/acceptance/safelisting_spec.rb".freeze, "spec/acceptance/safelisting_subnet_spec.rb".freeze, "spec/acceptance/stores/active_support_dalli_store_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_memory_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_store_spec.rb".freeze, "spec/acceptance/stores/connection_pool_dalli_client_spec.rb".freeze, "spec/acceptance/stores/dalli_client_spec.rb".freeze, "spec/acceptance/stores/redis_spec.rb".freeze, "spec/acceptance/stores/redis_store_spec.rb".freeze, "spec/acceptance/throttling_spec.rb".freeze, "spec/acceptance/track_spec.rb".freeze, "spec/acceptance/track_throttle_spec.rb".freeze, "spec/allow2ban_spec.rb".freeze, "spec/fail2ban_spec.rb".freeze, "spec/integration/offline_spec.rb".freeze, "spec/rack_attack_dalli_proxy_spec.rb".freeze, "spec/rack_attack_instrumentation_spec.rb".freeze, "spec/rack_attack_path_normalizer_spec.rb".freeze, "spec/rack_attack_request_spec.rb".freeze, "spec/rack_attack_spec.rb".freeze, "spec/rack_attack_throttle_spec.rb".freeze, "spec/rack_attack_track_spec.rb".freeze, "spec/spec_helper.rb".freeze, "spec/support/cache_store_helper.rb".freeze] s.homepage = "https://github.com/kickstarter/rack-attack".freeze s.licenses = ["MIT".freeze] s.rdoc_options = ["--charset=UTF-8".freeze] s.required_ruby_version = Gem::Requirement.new(">= 2.3".freeze) s.rubygems_version = "2.5.2.1".freeze s.summary = "Block & throttle abusive requests".freeze s.test_files = ["spec/acceptance/allow2ban_spec.rb".freeze, "spec/acceptance/blocking_ip_spec.rb".freeze, "spec/acceptance/blocking_spec.rb".freeze, "spec/acceptance/blocking_subnet_spec.rb".freeze, "spec/acceptance/cache_store_config_for_allow2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_fail2ban_spec.rb".freeze, "spec/acceptance/cache_store_config_for_throttle_spec.rb".freeze, "spec/acceptance/cache_store_config_with_rails_spec.rb".freeze, "spec/acceptance/customizing_blocked_response_spec.rb".freeze, "spec/acceptance/customizing_throttled_response_spec.rb".freeze, "spec/acceptance/extending_request_object_spec.rb".freeze, "spec/acceptance/fail2ban_spec.rb".freeze, "spec/acceptance/rails_middleware_spec.rb".freeze, "spec/acceptance/safelisting_ip_spec.rb".freeze, "spec/acceptance/safelisting_spec.rb".freeze, "spec/acceptance/safelisting_subnet_spec.rb".freeze, "spec/acceptance/stores/active_support_dalli_store_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_mem_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_memory_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_cache_store_spec.rb".freeze, "spec/acceptance/stores/active_support_redis_store_spec.rb".freeze, "spec/acceptance/stores/connection_pool_dalli_client_spec.rb".freeze, "spec/acceptance/stores/dalli_client_spec.rb".freeze, "spec/acceptance/stores/redis_spec.rb".freeze, "spec/acceptance/stores/redis_store_spec.rb".freeze, "spec/acceptance/throttling_spec.rb".freeze, "spec/acceptance/track_spec.rb".freeze, "spec/acceptance/track_throttle_spec.rb".freeze, "spec/allow2ban_spec.rb".freeze, "spec/fail2ban_spec.rb".freeze, "spec/integration/offline_spec.rb".freeze, "spec/rack_attack_dalli_proxy_spec.rb".freeze, "spec/rack_attack_instrumentation_spec.rb".freeze, "spec/rack_attack_path_normalizer_spec.rb".freeze, "spec/rack_attack_request_spec.rb".freeze, "spec/rack_attack_spec.rb".freeze, "spec/rack_attack_throttle_spec.rb".freeze, "spec/rack_attack_track_spec.rb".freeze, "spec/spec_helper.rb".freeze, "spec/support/cache_store_helper.rb".freeze] if s.respond_to? :specification_version then s.specification_version = 4 if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then s.add_development_dependency(%q.freeze, ["~> 2.2"]) s.add_development_dependency(%q.freeze, ["< 3.0", ">= 1.17"]) s.add_development_dependency(%q.freeze, ["~> 11.0"]) s.add_development_dependency(%q.freeze, ["~> 5.11"]) s.add_development_dependency(%q.freeze, ["~> 0.6"]) s.add_runtime_dependency(%q.freeze, ["< 3", ">= 1.0"]) s.add_development_dependency(%q.freeze, ["~> 1.0"]) s.add_development_dependency(%q.freeze, ["< 6.1", ">= 4.2"]) s.add_development_dependency(%q.freeze, ["~> 13.0"]) s.add_development_dependency(%q.freeze, ["= 0.78.0"]) s.add_development_dependency(%q.freeze, ["~> 1.5.0"]) s.add_development_dependency(%q.freeze, ["~> 0.9.1"]) else s.add_dependency(%q.freeze, ["~> 2.2"]) s.add_dependency(%q.freeze, ["< 3.0", ">= 1.17"]) s.add_dependency(%q.freeze, ["~> 11.0"]) s.add_dependency(%q.freeze, ["~> 5.11"]) s.add_dependency(%q.freeze, ["~> 0.6"]) s.add_dependency(%q.freeze, ["< 3", ">= 1.0"]) s.add_dependency(%q.freeze, ["~> 1.0"]) s.add_dependency(%q.freeze, ["< 6.1", ">= 4.2"]) s.add_dependency(%q.freeze, ["~> 13.0"]) s.add_dependency(%q.freeze, ["= 0.78.0"]) s.add_dependency(%q.freeze, ["~> 1.5.0"]) s.add_dependency(%q.freeze, ["~> 0.9.1"]) end else s.add_dependency(%q.freeze, ["~> 2.2"]) s.add_dependency(%q.freeze, ["< 3.0", ">= 1.17"]) s.add_dependency(%q.freeze, ["~> 11.0"]) s.add_dependency(%q.freeze, ["~> 5.11"]) s.add_dependency(%q.freeze, ["~> 0.6"]) s.add_dependency(%q.freeze, ["< 3", ">= 1.0"]) s.add_dependency(%q.freeze, ["~> 1.0"]) s.add_dependency(%q.freeze, ["< 6.1", ">= 4.2"]) s.add_dependency(%q.freeze, ["~> 13.0"]) s.add_dependency(%q.freeze, ["= 0.78.0"]) s.add_dependency(%q.freeze, ["~> 1.5.0"]) s.add_dependency(%q.freeze, ["~> 0.9.1"]) end end rack-attack-6.3.1/Rakefile0000644000004100000410000000107213661722201015403 0ustar www-datawww-data# 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| t.pattern = "spec/*_spec.rb" end 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 Rake::TestTask.new(:test) do |t| t.pattern = "spec/**/*_spec.rb" end task default: [:rubocop, :test] rack-attack-6.3.1/lib/0000755000004100000410000000000013661722201014504 5ustar www-datawww-datarack-attack-6.3.1/lib/rack/0000755000004100000410000000000013661722201015424 5ustar www-datawww-datarack-attack-6.3.1/lib/rack/attack/0000755000004100000410000000000013661722201016673 5ustar www-datawww-datarack-attack-6.3.1/lib/rack/attack/version.rb0000644000004100000410000000013213661722201020701 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack VERSION = '6.3.1' end end rack-attack-6.3.1/lib/rack/attack/cache.rb0000644000004100000410000000504713661722201020271 0ustar www-datawww-data# 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) @prefix = 'rack::attack' end attr_reader :store def store=(store) @store = StoreProxy.build(store) end def count(unprefixed_key, period) key, expires_in = key_and_expiry(unprefixed_key, period) do_count(key, expires_in) end def read(unprefixed_key) enforce_store_presence! enforce_store_method_presence!(:read) store.read("#{prefix}:#{unprefixed_key}") end def write(unprefixed_key, value, expires_in) store.write("#{prefix}:#{unprefixed_key}", value, expires_in: expires_in) end def reset_count(unprefixed_key, period) key, _ = key_and_expiry(unprefixed_key, period) store.delete(key) end def delete(unprefixed_key) store.delete("#{prefix}:#{unprefixed_key}") end def reset! if store.respond_to?(:delete_matched) store.delete_matched("#{prefix}*") else raise( Rack::Attack::IncompatibleStoreError, "Configured store #{store.class.name} doesn't respond to #delete_matched method" ) end end private def key_and_expiry(unprefixed_key, period) @last_epoch_time = Time.now.to_i # Add 1 to expires_in to avoid timing error: https://git.io/i1PHXA 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, "Configured store #{store.class.name} doesn't respond to ##{method_name} method" ) end end end end end rack-attack-6.3.1/lib/rack/attack/fail2ban.rb0000644000004100000410000000316013661722201020676 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Fail2Ban class << self def filter(discriminator, options) bantime = options[:bantime] or raise ArgumentError, "Must pass bantime option" findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option" maxretry = options[:maxretry] or raise ArgumentError, "Must pass maxretry option" if banned?(discriminator) # Return true for blocklist true elsif yield fail!(discriminator, bantime, findtime, maxretry) end end def reset(discriminator, options) findtime = options[:findtime] or raise ArgumentError, "Must pass findtime option" cache.reset_count("#{key_prefix}:count:#{discriminator}", findtime) # Clear ban flag just in case it's there cache.delete("#{key_prefix}:ban:#{discriminator}") end def banned?(discriminator) cache.read("#{key_prefix}:ban:#{discriminator}") ? true : false end protected def key_prefix 'fail2ban' end def fail!(discriminator, bantime, findtime, maxretry) count = cache.count("#{key_prefix}:count:#{discriminator}", findtime) if count >= maxretry ban!(discriminator, bantime) end true end private def ban!(discriminator, bantime) cache.write("#{key_prefix}:ban:#{discriminator}", 1, bantime) end def cache Rack::Attack.cache end end end end end rack-attack-6.3.1/lib/rack/attack/safelist.rb0000644000004100000410000000030213661722201021025 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Safelist < Check def initialize(name = nil, &block) super @type = :safelist end end end end rack-attack-6.3.1/lib/rack/attack/railtie.rb0000644000004100000410000000046713661722201020660 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Railtie < ::Rails::Railtie initializer "rack-attack.middleware" do |app| if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("5.1") app.middleware.use(Rack::Attack) end end end end end rack-attack-6.3.1/lib/rack/attack/path_normalizer.rb0000644000004100000410000000157213661722201022423 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack # When using Rack::Attack with a Rails app, developers expect the request path # to be normalized. In particular, trailing slashes are stripped. # (See https://git.io/v0rrR for implementation.) # # Look for an ActionDispatch utility class that Rails folks would expect # to normalize request paths. If unavailable, use a fallback class that # doesn't normalize the path (as a non-Rails rack app developer expects). module FallbackPathNormalizer def self.normalize_path(path) path end end PathNormalizer = if defined?(::ActionDispatch::Journey::Router::Utils) # For Rails apps ::ActionDispatch::Journey::Router::Utils else FallbackPathNormalizer end end end rack-attack-6.3.1/lib/rack/attack/request.rb0000644000004100000410000000072213661722201020711 0ustar www-datawww-data# 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 # through monkey patching: # # class Rack::Attack::Request < ::Rack::Request # def localhost? # ip == "127.0.0.1" # end # end # # Rack::Attack.safelist("localhost") {|req| req.localhost? } # module Rack class Attack class Request < ::Rack::Request end end end rack-attack-6.3.1/lib/rack/attack/throttle.rb0000644000004100000410000000414513661722201021071 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Throttle MANDATORY_OPTIONS = [:limit, :period].freeze attr_reader :name, :limit, :period, :block, :type def initialize(name, options, &block) @name = name @block = block MANDATORY_OPTIONS.each do |opt| raise ArgumentError, "Must pass #{opt.inspect} option" unless options[opt] end @limit = options[:limit] @period = options[:period].respond_to?(:call) ? options[:period] : options[:period].to_i @type = options.fetch(:type, :throttle) end def cache Rack::Attack.cache end def matched_by?(request) discriminator = block.call(request) return false unless discriminator current_period = period_for(request) current_limit = limit_for(request) count = cache.count("#{name}:#{discriminator}", current_period) data = { discriminator: discriminator, count: count, period: current_period, limit: current_limit, epoch_time: cache.last_epoch_time } (count > current_limit).tap do |throttled| annotate_request_with_throttle_data(request, data) if throttled annotate_request_with_matched_data(request, data) Rack::Attack.instrument(request) end end end private def period_for(request) period.respond_to?(:call) ? period.call(request) : period end def limit_for(request) limit.respond_to?(:call) ? limit.call(request) : limit end def annotate_request_with_throttle_data(request, data) (request.env['rack.attack.throttle_data'] ||= {})[name] = data end def annotate_request_with_matched_data(request, data) request.env['rack.attack.matched'] = name request.env['rack.attack.match_discriminator'] = data[:discriminator] request.env['rack.attack.match_type'] = type request.env['rack.attack.match_data'] = data end end end end rack-attack-6.3.1/lib/rack/attack/configuration.rb0000644000004100000410000000605313661722201022073 0ustar www-datawww-data# frozen_string_literal: true require "ipaddr" module Rack class Attack class Configuration DEFAULT_BLOCKLISTED_RESPONSE = lambda { |_env| [403, { 'Content-Type' => 'text/plain' }, ["Forbidden\n"]] } DEFAULT_THROTTLED_RESPONSE = lambda do |env| if Rack::Attack.configuration.throttled_response_retry_after_header match_data = env['rack.attack.match_data'] now = match_data[:epoch_time] retry_after = match_data[:period] - (now % match_data[:period]) [429, { 'Content-Type' => 'text/plain', 'Retry-After' => retry_after.to_s }, ["Retry later\n"]] else [429, { 'Content-Type' => 'text/plain' }, ["Retry later\n"]] end end attr_reader :safelists, :blocklists, :throttles, :anonymous_blocklists, :anonymous_safelists attr_accessor :blocklisted_response, :throttled_response, :throttled_response_retry_after_header def initialize set_defaults end def safelist(name = nil, &block) safelist = Safelist.new(name, &block) if name @safelists[name] = safelist else @anonymous_safelists << safelist end end def blocklist(name = nil, &block) blocklist = Blocklist.new(name, &block) if name @blocklists[name] = blocklist else @anonymous_blocklists << blocklist end end def blocklist_ip(ip_address) @anonymous_blocklists << Blocklist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } end def safelist_ip(ip_address) @anonymous_safelists << Safelist.new { |request| IPAddr.new(ip_address).include?(IPAddr.new(request.ip)) } end def throttle(name, options, &block) @throttles[name] = Throttle.new(name, options, &block) end def track(name, options = {}, &block) @tracks[name] = Track.new(name, options, &block) end def safelisted?(request) @anonymous_safelists.any? { |safelist| safelist.matched_by?(request) } || @safelists.any? { |_name, safelist| safelist.matched_by?(request) } end def blocklisted?(request) @anonymous_blocklists.any? { |blocklist| blocklist.matched_by?(request) } || @blocklists.any? { |_name, blocklist| blocklist.matched_by?(request) } end def throttled?(request) @throttles.any? do |_name, throttle| throttle.matched_by?(request) end end def tracked?(request) @tracks.each_value do |track| track.matched_by?(request) end end def clear_configuration set_defaults end private def set_defaults @safelists = {} @blocklists = {} @throttles = {} @tracks = {} @anonymous_blocklists = [] @anonymous_safelists = [] @throttled_response_retry_after_header = false @blocklisted_response = DEFAULT_BLOCKLISTED_RESPONSE @throttled_response = DEFAULT_THROTTLED_RESPONSE end end end end rack-attack-6.3.1/lib/rack/attack/track.rb0000644000004100000410000000073613661722201020332 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Track attr_reader :filter def initialize(name, options = {}, &block) options[:type] = :track @filter = if options[:limit] && options[:period] Throttle.new(name, options, &block) else Check.new(name, options, &block) end end def matched_by?(request) filter.matched_by?(request) end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy.rb0000644000004100000410000000066113661722201021620 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack module StoreProxy PROXIES = [ DalliProxy, MemCacheStoreProxy, RedisStoreProxy, RedisProxy, RedisCacheStoreProxy, ActiveSupportRedisStoreProxy ].freeze def self.build(store) klass = PROXIES.find { |proxy| proxy.handle?(store) } klass ? klass.new(store) : store end end end end rack-attack-6.3.1/lib/rack/attack/blocklist.rb0000644000004100000410000000030413661722201021203 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Blocklist < Check def initialize(name = nil, &block) super @type = :blocklist end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy/0000755000004100000410000000000013661722201021270 5ustar www-datawww-datarack-attack-6.3.1/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb0000644000004100000410000000204213661722201030525 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class ActiveSupportRedisStoreProxy < SimpleDelegator def self.handle?(store) defined?(::Redis) && defined?(::ActiveSupport::Cache::RedisStore) && store.is_a?(::ActiveSupport::Cache::RedisStore) end def increment(name, amount = 1, options = {}) # #increment ignores options[:expires_in]. # # So in order to workaround this we use #write (which sets expiration) to initialize # the counter. After that we continue using the original #increment. if options[:expires_in] && !read(name) write(name, amount, options) amount else super end end def read(name, options = {}) super(name, options.merge!(raw: true)) end def write(name, value, options = {}) super(name, value, options.merge!(raw: true)) end end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb0000644000004100000410000000176613661722201026535 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class RedisCacheStoreProxy < SimpleDelegator def self.handle?(store) store.class.name == "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 rack-attack-6.3.1/lib/rack/attack/store_proxy/redis_store_proxy.rb0000644000004100000410000000114213661722201025376 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class RedisStoreProxy < RedisProxy def self.handle?(store) defined?(::Redis::Store) && store.is_a?(::Redis::Store) end def read(key) rescuing { get(key, raw: true) } end def write(key, value, options = {}) if (expires_in = options[:expires_in]) rescuing { setex(key, expires_in, value, raw: true) } else rescuing { set(key, value, raw: true) } end end end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb0000644000004100000410000000075413661722201026201 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class MemCacheStoreProxy < SimpleDelegator def self.handle?(store) defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore) end def write(name, value, options = {}) super(name, value, options.merge!(raw: true)) end end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy/dalli_proxy.rb0000644000004100000410000000335713661722201024153 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class DalliProxy < SimpleDelegator def self.handle?(store) return false unless defined?(::Dalli) # Consider extracting to a separate Connection Pool proxy to reduce # code here and handle clients other than Dalli. if defined?(::ConnectionPool) && store.is_a?(::ConnectionPool) store.with { |conn| conn.is_a?(::Dalli::Client) } else store.is_a?(::Dalli::Client) end end def initialize(client) super(client) stub_with_if_missing end def read(key) rescuing do with do |client| client.get(key) end end end def write(key, value, options = {}) rescuing do with do |client| client.set(key, value, options.fetch(:expires_in, 0), raw: true) end end end def increment(key, amount, options = {}) rescuing do with do |client| client.incr(key, amount, options.fetch(:expires_in, 0), amount) end end end def delete(key) rescuing do with do |client| client.delete(key) end end end private def stub_with_if_missing unless __getobj__.respond_to?(:with) class << self def with yield __getobj__ end end end end def rescuing yield rescue Dalli::DalliError nil end end end end end rack-attack-6.3.1/lib/rack/attack/store_proxy/redis_proxy.rb0000644000004100000410000000313313661722201024164 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack module StoreProxy class RedisProxy < SimpleDelegator def initialize(*args) if Gem::Version.new(Redis::VERSION) < Gem::Version.new("3") warn 'RackAttack requires Redis gem >= 3.0.0.' end super(*args) end def self.handle?(store) defined?(::Redis) && store.is_a?(::Redis) end def read(key) rescuing { get(key) } end def write(key, value, options = {}) if (expires_in = options[:expires_in]) rescuing { setex(key, expires_in, value) } else rescuing { set(key, value) } end end def increment(key, amount, options = {}) rescuing do pipelined do incrby(key, amount) expire(key, options[:expires_in]) if options[:expires_in] end.first end end def delete(key, _options = {}) rescuing { del(key) } end def delete_matched(matcher, _options = nil) cursor = "0" rescuing do # Fetch keys in batches using SCAN to avoid blocking the Redis server. loop do cursor, keys = scan(cursor, match: matcher, count: 1000) del(*keys) unless keys.empty? break if cursor == "0" end end end private def rescuing yield rescue Redis::BaseConnectionError nil end end end end end rack-attack-6.3.1/lib/rack/attack/allow2ban.rb0000644000004100000410000000124613661722201021104 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Allow2Ban < Fail2Ban class << self protected def key_prefix 'allow2ban' end # everything is the same here except we only return true # (blocking the request) if they have tripped the limit. def fail!(discriminator, bantime, findtime, maxretry) count = cache.count("#{key_prefix}:count:#{discriminator}", findtime) if count >= maxretry ban!(discriminator, bantime) end # we may not block them this time, but they're banned for next time false end end end end end rack-attack-6.3.1/lib/rack/attack/check.rb0000644000004100000410000000106213661722201020274 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Check attr_reader :name, :block, :type def initialize(name, options = {}, &block) @name = name @block = block @type = options.fetch(:type, nil) end def matched_by?(request) block.call(request).tap do |match| if match request.env["rack.attack.matched"] = name request.env["rack.attack.match_type"] = type Rack::Attack.instrument(request) end end end end end end rack-attack-6.3.1/lib/rack/attack.rb0000644000004100000410000000702513661722201017224 0ustar www-datawww-data# frozen_string_literal: true require 'rack' require 'forwardable' require 'rack/attack/cache' require 'rack/attack/configuration' require 'rack/attack/path_normalizer' require 'rack/attack/request' require 'rack/attack/railtie' if defined?(::Rails) module Rack class Attack class Error < StandardError; end class MisconfiguredStoreError < Error; end class MissingStoreError < Error; end class IncompatibleStoreError < Error; end autoload :Check, 'rack/attack/check' autoload :Throttle, 'rack/attack/throttle' autoload :Safelist, 'rack/attack/safelist' autoload :Blocklist, 'rack/attack/blocklist' autoload :Track, 'rack/attack/track' autoload :StoreProxy, 'rack/attack/store_proxy' autoload :DalliProxy, 'rack/attack/store_proxy/dalli_proxy' autoload :MemCacheStoreProxy, 'rack/attack/store_proxy/mem_cache_store_proxy' autoload :RedisProxy, 'rack/attack/store_proxy/redis_proxy' autoload :RedisStoreProxy, 'rack/attack/store_proxy/redis_store_proxy' autoload :RedisCacheStoreProxy, 'rack/attack/store_proxy/redis_cache_store_proxy' autoload :ActiveSupportRedisStoreProxy, 'rack/attack/store_proxy/active_support_redis_store_proxy' autoload :Fail2Ban, 'rack/attack/fail2ban' autoload :Allow2Ban, 'rack/attack/allow2ban' class << self attr_accessor :enabled, :notifier attr_reader :configuration def instrument(request) if notifier event_type = request.env["rack.attack.match_type"] notifier.instrument("#{event_type}.rack_attack", request: request) # Deprecated: Keeping just for backwards compatibility notifier.instrument("rack.attack", request: request) end end def cache @cache ||= Cache.new end def clear! warn "[DEPRECATION] Rack::Attack.clear! is deprecated. Please use Rack::Attack.clear_configuration instead" @configuration.clear_configuration end def reset! cache.reset! end extend Forwardable def_delegators( :@configuration, :safelist, :blocklist, :blocklist_ip, :safelist_ip, :throttle, :track, :blocklisted_response, :blocklisted_response=, :throttled_response, :throttled_response=, :throttled_response_retry_after_header, :throttled_response_retry_after_header=, :clear_configuration, :safelists, :blocklists, :throttles, :tracks ) end # Set defaults @enabled = true @notifier = ActiveSupport::Notifications if defined?(ActiveSupport::Notifications) @configuration = Configuration.new attr_reader :configuration def initialize(app) @app = app @configuration = self.class.configuration end def call(env) return @app.call(env) if !self.class.enabled || env["rack.attack.called"] env["rack.attack.called"] = true env['PATH_INFO'] = PathNormalizer.normalize_path(env['PATH_INFO']) request = Rack::Attack::Request.new(env) if configuration.safelisted?(request) @app.call(env) elsif configuration.blocklisted?(request) configuration.blocklisted_response.call(env) elsif configuration.throttled?(request) configuration.throttled_response.call(env) else configuration.tracked?(request) @app.call(env) end end end end