rack-attack-6.7.0/0000755000004100000410000000000014537323523013750 5ustar www-datawww-datarack-attack-6.7.0/README.md0000644000004100000410000004457614537323523015247 0ustar www-datawww-data:warning: You are viewing the development's branch version of README which might contain documentation for unreleased features. For the README consistent with the latest released version see https://github.com/rack/rack-attack/blob/6-stable/README.md. # Rack::Attack *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](https://github.com/rack/rack-attack/actions/workflows/build.yml/badge.svg)](https://github.com/rack/rack-attack/actions/workflows/build.yml) [![Code Climate](https://codeclimate.com/github/kickstarter/rack-attack.svg)](https://codeclimate.com/github/kickstarter/rack-attack) [![Join the chat at https://gitter.im/rack-attack/rack-attack](https://badges.gitter.im/rack-attack/rack-attack.svg)](https://gitter.im/rack-attack/rack-attack) ## 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) - [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 it is used by default. You can disable it permanently (like for specific environment) or temporarily (can be useful for specific test cases) by writing: ```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["HTTP_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 *normalized* email as a discriminator on POST /login requests Rack::Attack.throttle('limit logins per email', limit: 6, period: 60) do |req| if req.path == '/login' && req.post? # Normalize the email, using the same logic as your authentication process, to # protect against rate limit bypasses. req.params['email'].to_s.downcase.gsub(/\s+/, "") end end # 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 # This is the default Rack::Attack.cache.store = Rails.cache # It is recommended to use a separate database for throttling/allow2ban/fail2ban. Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: "...") ``` Most applications should use a new, separate database used only for `rack-attack`. During an actual attack or periods of heavy load, this database will come under heavy load. Keeping it on a separate database instance will give you additional resilience and make sure that other functions (like caching for your application) don't go down. Note that `Rack::Attack.cache` is only used for throttling, allow2ban and fail2ban filtering; not blocklisting and safelisting. Your cache store must implement `increment` and `write` like [ActiveSupport::Cache::Store](http://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html). This means that other cache stores which inherit from ActiveSupport::Cache::Store are also compatible. In-memory stores which are not backed by an external database, such as `ActiveSupport::Cache::MemoryStore.new`, will be mostly ineffective because each Ruby process in your deployment will have it's own state, effectively multiplying the number of requests each client can make by the number of Ruby processes you have deployed. ## Customizing responses Customize the response of blocklisted and throttled requests using an object that adheres to the [Rack app interface](http://www.rubydoc.info/github/rack/rack/file/SPEC.rdoc). ```ruby Rack::Attack.blocklisted_responder = lambda do |request| # Using 503 because it may make attacker think that they have successfully # DOSed the site. Rack::Attack returns 403 for blocklists by default [ 503, {}, ['Blocked']] end Rack::Attack.throttled_responder = lambda do |request| # NB: you have access to the name and other data about the matched throttle # request.env['rack.attack.matched'], # request.env['rack.attack.match_type'], # request.env['rack.attack.match_data'], # request.env['rack.attack.match_discriminator'] # Using 503 because it may make attacker think that they have successfully # DOSed the site. Rack::Attack returns 429 for throttling by default [ 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 they 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_responder = lambda do |request| match_data = request.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` namespace. 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 A note on developing and testing apps using Rack::Attack - if you are using throttling in particular, you will need to enable the cache in your development environment. See [Caching with Rails](http://guides.rubyonrails.org/caching_with_rails.html) for more on how to do this. ### Disabling `Rack::Attack.enabled = false` can be used to either completely disable Rack::Attack in your tests, or to disable/enable for specific test cases only. ### Test case isolation `Rack::Attack.reset!` can be used in your test suite to clear any Rack::Attack state between different test cases. If you're testing blocklist and safelist configurations, consider using `Rack::Attack.clear_configuration` to unset the values for those lists between test cases. ## How it works The Rack::Attack middleware compares each request against *safelists*, *blocklists*, *throttles*, and *tracks* that you define. There are none by default. * 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_responder.call(req) elsif throttled?(req) self.class.throttled_responder.call(req) 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. ## 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.7.0/spec/0000755000004100000410000000000014537323523014702 5ustar www-datawww-datarack-attack-6.7.0/spec/allow2ban_spec.rb0000644000004100000410000000674014537323523020131 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.7.0/spec/rack_attack_throttle_spec.rb0000644000004100000410000001300514537323523022434 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 describe 'Rack::Attack.throttle with throttle_discriminator_normalizer' do before do @period = 60 @emails = [ "person@example.com", "PERSON@example.com ", " person@example.com\r\n ", ] Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new Rack::Attack.throttle('logins/email', limit: 4, period: @period) do |req| if req.path == '/login' && req.post? req.params['email'] end end end it 'should not differentiate requests when throttle_discriminator_normalizer is enabled' do post_logins key = "rack::attack:#{Time.now.to_i / @period}:logins/email:person@example.com" _(Rack::Attack.cache.store.read(key)).must_equal 3 end it 'should differentiate requests when throttle_discriminator_normalizer is disabled' do begin prev = Rack::Attack.throttle_discriminator_normalizer Rack::Attack.throttle_discriminator_normalizer = nil post_logins @emails.each do |email| key = "rack::attack:#{Time.now.to_i / @period}:logins/email:#{email}" _(Rack::Attack.cache.store.read(key)).must_equal 1 end ensure Rack::Attack.throttle_discriminator_normalizer = prev end end def post_logins @emails.each do |email| post '/login', email: email end end end rack-attack-6.7.0/spec/rack_attack_path_normalizer_spec.rb0000644000004100000410000000072314537323523023770 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.7.0/spec/acceptance/0000755000004100000410000000000014537323523016770 5ustar www-datawww-datarack-attack-6.7.0/spec/acceptance/cache_store_config_for_allow2ban_spec.rb0000644000004100000410000000611514537323523026745 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.7.0/spec/acceptance/throttling_spec.rb0000644000004100000410000001223114537323523022524 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.7.0/spec/acceptance/allow2ban_spec.rb0000644000004100000410000000336314537323523022215 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.7.0/spec/acceptance/track_throttle_spec.rb0000644000004100000410000000270714537323523023366 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.7.0/spec/acceptance/stores/0000755000004100000410000000000014537323523020307 5ustar www-datawww-datarack-attack-6.7.0/spec/acceptance/stores/active_support_redis_cache_store_pooled_spec.rb0000644000004100000410000000131014537323523031777 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.read(key) }) end end rack-attack-6.7.0/spec/acceptance/stores/active_support_redis_store_spec.rb0000644000004100000410000000101714537323523027316 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.7.0/spec/acceptance/stores/active_support_mem_cache_store_pooled_spec.rb0000644000004100000410000000105714537323523031457 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.7.0/spec/acceptance/stores/active_support_redis_cache_store_spec.rb0000644000004100000410000000122114537323523030436 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.read(key) }) end end rack-attack-6.7.0/spec/acceptance/stores/active_support_dalli_store_spec.rb0000644000004100000410000000117514537323523027302 0ustar www-datawww-data# frozen_string_literal: true require_relative "../../spec_helper" should_run = defined?(::Dalli) && Gem::Version.new(::Dalli::VERSION) < Gem::Version.new("3") if should_run require_relative "../../support/cache_store_helper" require "active_support/cache/dalli_store" require "timecop" 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.7.0/spec/acceptance/stores/redis_store_spec.rb0000644000004100000410000000075114537323523024173 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.7.0/spec/acceptance/stores/connection_pool_dalli_client_spec.rb0000644000004100000410000000121014537323523027533 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.7.0/spec/acceptance/stores/active_support_mem_cache_store_spec.rb0000644000004100000410000000077214537323523030120 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.7.0/spec/acceptance/stores/dalli_client_spec.rb0000644000004100000410000000074514537323523024277 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.7.0/spec/acceptance/stores/redis_spec.rb0000644000004100000410000000070514537323523022756 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.7.0/spec/acceptance/stores/active_support_memory_store_spec.rb0000644000004100000410000000071014537323523027517 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.7.0/spec/acceptance/blocking_subnet_spec.rb0000644000004100000410000000212114537323523023473 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.7.0/spec/acceptance/customizing_blocked_response_spec.rb0000644000004100000410000000253414537323523026307 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_responder = lambda do |_req| [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_responder = lambda do |req| matched = req.env['rack.attack.matched'] match_type = req.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 it "supports old style" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 403, last_response.status silence_warnings do Rack::Attack.blocklisted_response = lambda do |_env| [503, {}, ["Blocked"]] end end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Blocked", last_response.body end end rack-attack-6.7.0/spec/acceptance/fail2ban_spec.rb0000644000004100000410000000351214537323523022006 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.7.0/spec/acceptance/cache_store_config_with_rails_spec.rb0000644000004100000410000000151414537323523026361 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.7.0/spec/acceptance/cache_store_config_for_throttle_spec.rb0000644000004100000410000000223714537323523026732 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.7.0/spec/acceptance/blocking_ip_spec.rb0000644000004100000410000000163414537323523022613 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.7.0/spec/acceptance/track_spec.rb0000644000004100000410000000145514537323523021440 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.7.0/spec/acceptance/customizing_throttled_response_spec.rb0000644000004100000410000000405014537323523026710 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_responder = lambda do |_req| [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_responder = lambda do |req| matched = req.env['rack.attack.matched'] match_type = req.env['rack.attack.match_type'] match_data = req.env['rack.attack.match_data'] match_discriminator = req.env['rack.attack.match_discriminator'] [429, {}, ["Throttled"]] end 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 it "supports old style" do get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 200, last_response.status get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 429, last_response.status silence_warnings do Rack::Attack.throttled_response = lambda do |_req| [503, {}, ["Throttled"]] end end get "/", {}, "REMOTE_ADDR" => "1.2.3.4" assert_equal 503, last_response.status assert_equal "Throttled", last_response.body end end rack-attack-6.7.0/spec/acceptance/blocking_spec.rb0000644000004100000410000000422514537323523022122 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.7.0/spec/acceptance/extending_request_object_spec.rb0000644000004100000410000000137514537323523025420 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.7.0/spec/acceptance/cache_store_config_for_fail2ban_spec.rb0000644000004100000410000000563114537323523026544 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.7.0/spec/acceptance/safelisting_ip_spec.rb0000644000004100000410000000251514537323523023332 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.7.0/spec/acceptance/safelisting_spec.rb0000644000004100000410000000576714537323523022656 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.7.0/spec/acceptance/safelisting_subnet_spec.rb0000644000004100000410000000253614537323523024225 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.7.0/spec/acceptance/rails_middleware_spec.rb0000644000004100000410000000104114537323523023632 0ustar www-datawww-data# frozen_string_literal: true require_relative "../spec_helper" if defined?(Rails::Application) 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 it "is used by default" do @app.initialize! assert @app.middleware.include?(Rack::Attack) end end end rack-attack-6.7.0/spec/rack_attack_instrumentation_spec.rb0000644000004100000410000000233414537323523024035 0ustar www-datawww-data# frozen_string_literal: true require_relative "spec_helper" require 'active_support' # 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.7.0/spec/rack_attack_track_spec.rb0000644000004100000410000000271414537323523021700 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.7.0/spec/fail2ban_spec.rb0000644000004100000410000001010714537323523017716 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.7.0/spec/rack_attack_spec.rb0000644000004100000410000000676014537323523020521 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 if Rack::Attack::PathNormalizer == Rack::Attack::FallbackPathNormalizer skip "Normalization is only present on Rails" end 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_responder' do it 'should exist' do _(Rack::Attack.blocklisted_responder).must_respond_to :call end end describe '#throttled_responder' do it 'should exist' do _(Rack::Attack.throttled_responder).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.7.0/spec/spec_helper.rb0000644000004100000410000000251414537323523017522 0ustar www-datawww-data# frozen_string_literal: true require "bundler/setup" require "minitest/autorun" require "minitest/pride" require "rack/test" require "active_support" 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 if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) Rails.cache.clear end end after do Rack::Attack.clear_configuration Rack::Attack.instance_variable_set(:@cache, nil) 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.7.0/spec/rack_attack_dalli_proxy_spec.rb0000644000004100000410000000044014537323523023114 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.7.0/spec/support/0000755000004100000410000000000014537323523016416 5ustar www-datawww-datarack-attack-6.7.0/spec/support/cache_store_helper.rb0000644000004100000410000000423314537323523022563 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.7.0/spec/integration/0000755000004100000410000000000014537323523017225 5ustar www-datawww-datarack-attack-6.7.0/spec/integration/offline_spec.rb0000644000004100000410000000412314537323523022206 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.7.0/spec/rack_attack_request_spec.rb0000644000004100000410000000055114537323523022261 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.7.0/rack-attack.gemspec0000644000004100000410000002027114537323523017504 0ustar www-datawww-data######################################################### # This file has been automatically generated by gem2tgz # ######################################################### # -*- encoding: utf-8 -*- # stub: rack-attack 6.7.0 ruby lib Gem::Specification.new do |s| s.name = "rack-attack".freeze s.version = "6.7.0" s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= s.metadata = { "bug_tracker_uri" => "https://github.com/rack/rack-attack/issues", "changelog_uri" => "https://github.com/rack/rack-attack/blob/main/CHANGELOG.md", "source_code_uri" => "https://github.com/rack/rack-attack" } if s.respond_to? :metadata= s.require_paths = ["lib".freeze] s.authors = ["Aaron Suggs".freeze] s.date = "2023-07-26" s.description = "A rack middleware for throttling and blocking abusive requests".freeze s.email = "aaron@ktheory.com".freeze s.files = ["LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "lib/rack/attack.rb".freeze, "lib/rack/attack/allow2ban.rb".freeze, "lib/rack/attack/base_proxy.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/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/rack/rack-attack".freeze s.licenses = ["MIT".freeze] s.rdoc_options = ["--charset=UTF-8".freeze] s.required_ruby_version = Gem::Requirement.new(">= 2.4".freeze) s.rubygems_version = "3.3.15".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 end if s.respond_to? :add_runtime_dependency then s.add_development_dependency(%q.freeze, [">= 0"]) s.add_development_dependency(%q.freeze, ["~> 2.2"]) s.add_development_dependency(%q.freeze, [">= 1.17", "< 3.0"]) 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, [">= 1.0", "< 4"]) s.add_development_dependency(%q.freeze, ["~> 2.0"]) s.add_development_dependency(%q.freeze, ["~> 13.0"]) s.add_development_dependency(%q.freeze, ["= 0.89.1"]) s.add_development_dependency(%q.freeze, ["~> 1.5.0"]) s.add_development_dependency(%q.freeze, ["~> 0.9.1"]) else s.add_dependency(%q.freeze, [">= 0"]) s.add_dependency(%q.freeze, ["~> 2.2"]) s.add_dependency(%q.freeze, [">= 1.17", "< 3.0"]) 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, [">= 1.0", "< 4"]) s.add_dependency(%q.freeze, ["~> 2.0"]) s.add_dependency(%q.freeze, ["~> 13.0"]) s.add_dependency(%q.freeze, ["= 0.89.1"]) s.add_dependency(%q.freeze, ["~> 1.5.0"]) s.add_dependency(%q.freeze, ["~> 0.9.1"]) end end rack-attack-6.7.0/LICENSE0000644000004100000410000000206514537323523014760 0ustar www-datawww-dataThe MIT License Copyright (c) 2016 Kickstarter, PBC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. rack-attack-6.7.0/Rakefile0000644000004100000410000000107214537323523015415 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.7.0/lib/0000755000004100000410000000000014537323523014516 5ustar www-datawww-datarack-attack-6.7.0/lib/rack/0000755000004100000410000000000014537323523015436 5ustar www-datawww-datarack-attack-6.7.0/lib/rack/attack/0000755000004100000410000000000014537323523016705 5ustar www-datawww-datarack-attack-6.7.0/lib/rack/attack/version.rb0000644000004100000410000000013214537323523020713 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack VERSION = '6.7.0' end end rack-attack-6.7.0/lib/rack/attack/cache.rb0000644000004100000410000000546114537323523020303 0ustar www-datawww-data# frozen_string_literal: true module Rack class Attack class Cache attr_accessor :prefix attr_reader :last_epoch_time def self.default_store if Object.const_defined?(:Rails) && Rails.respond_to?(:cache) ::Rails.cache end end def initialize(store: self.class.default_store) self.store = store @prefix = 'rack::attack' end attr_reader :store def store=(store) @store = if (proxy = BaseProxy.lookup(store)) proxy.new(store) else store end 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://github.com/rack/rack-attack/pull/85 expires_in = (period - (@last_epoch_time % period) + 1).to_i ["#{prefix}:#{(@last_epoch_time / period).to_i}:#{unprefixed_key}", expires_in] end 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.7.0/lib/rack/attack/fail2ban.rb0000644000004100000410000000316014537323523020710 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.7.0/lib/rack/attack/safelist.rb0000644000004100000410000000030214537323523021037 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.7.0/lib/rack/attack/railtie.rb0000644000004100000410000000042714537323523020666 0ustar www-datawww-data# frozen_string_literal: true begin require 'rails/railtie' rescue LoadError return end module Rack class Attack class Railtie < ::Rails::Railtie initializer "rack-attack.middleware" do |app| app.middleware.use(Rack::Attack) end end end end rack-attack-6.7.0/lib/rack/attack/path_normalizer.rb0000644000004100000410000000173314537323523022434 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://github.com/rails/rails/blob/f8edd20/actionpack/lib/action_dispatch/journey/router/utils.rb#L5-L22 # for implementation.) # # Look for an ActionDispatch utility class that Rails folks would expect # to normalize request paths. If unavailable, use a fallback class that # 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.7.0/lib/rack/attack/request.rb0000644000004100000410000000072214537323523020723 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.7.0/lib/rack/attack/throttle.rb0000644000004100000410000000462214537323523021103 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 = discriminator_for(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 discriminator_for(request) discriminator = block.call(request) if discriminator && Rack::Attack.throttle_discriminator_normalizer discriminator = Rack::Attack.throttle_discriminator_normalizer.call(discriminator) end discriminator end def period_for(request) period.respond_to?(:call) ? period.call(request) : period end 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.7.0/lib/rack/attack/configuration.rb0000644000004100000410000000740514537323523022107 0ustar www-datawww-data# frozen_string_literal: true require "ipaddr" module Rack class Attack class Configuration DEFAULT_BLOCKLISTED_RESPONDER = lambda { |_req| [403, { 'content-type' => 'text/plain' }, ["Forbidden\n"]] } DEFAULT_THROTTLED_RESPONDER = lambda do |req| if Rack::Attack.configuration.throttled_response_retry_after_header match_data = req.env['rack.attack.match_data'] now = match_data[:epoch_time] retry_after = match_data[:period] - (now % match_data[:period]) [429, { 'content-type' => 'text/plain', 'retry-after' => retry_after.to_s }, ["Retry later\n"]] else [429, { 'content-type' => 'text/plain' }, ["Retry later\n"]] end end attr_reader :safelists, :blocklists, :throttles, :anonymous_blocklists, :anonymous_safelists attr_accessor :blocklisted_responder, :throttled_responder, :throttled_response_retry_after_header attr_reader :blocklisted_response, :throttled_response # Keeping these for backwards compatibility def blocklisted_response=(responder) warn "[DEPRECATION] Rack::Attack.blocklisted_response is deprecated. "\ "Please use Rack::Attack.blocklisted_responder instead." @blocklisted_response = responder end def throttled_response=(responder) warn "[DEPRECATION] Rack::Attack.throttled_response is deprecated. "\ "Please use Rack::Attack.throttled_responder instead" @throttled_response = responder end def initialize set_defaults end def safelist(name = nil, &block) safelist = Safelist.new(name, &block) 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_responder = DEFAULT_BLOCKLISTED_RESPONDER @throttled_responder = DEFAULT_THROTTLED_RESPONDER # Deprecated: Keeping these for backwards compatibility @blocklisted_response = nil @throttled_response = nil end end end end rack-attack-6.7.0/lib/rack/attack/track.rb0000644000004100000410000000073614537323523020344 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.7.0/lib/rack/attack/blocklist.rb0000644000004100000410000000030414537323523021215 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.7.0/lib/rack/attack/base_proxy.rb0000644000004100000410000000072014537323523021404 0ustar www-datawww-data# frozen_string_literal: true require 'delegate' module Rack class Attack class BaseProxy < SimpleDelegator class << self def proxies @@proxies ||= [] end def inherited(klass) proxies << klass end def lookup(store) proxies.find { |proxy| proxy.handle?(store) } end def handle?(_store) raise NotImplementedError end end end end end rack-attack-6.7.0/lib/rack/attack/store_proxy/0000755000004100000410000000000014537323523021302 5ustar www-datawww-datarack-attack-6.7.0/lib/rack/attack/store_proxy/active_support_redis_store_proxy.rb0000644000004100000410000000205214537323523030540 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class ActiveSupportRedisStoreProxy < BaseProxy 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.7.0/lib/rack/attack/store_proxy/redis_cache_store_proxy.rb0000644000004100000410000000177314537323523026545 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class RedisCacheStoreProxy < BaseProxy 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.7.0/lib/rack/attack/store_proxy/redis_store_proxy.rb0000644000004100000410000000117514537323523025416 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/store_proxy/redis_proxy' 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.7.0/lib/rack/attack/store_proxy/mem_cache_store_proxy.rb0000644000004100000410000000112714537323523026206 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class MemCacheStoreProxy < BaseProxy def self.handle?(store) defined?(::Dalli) && defined?(::ActiveSupport::Cache::MemCacheStore) && store.is_a?(::ActiveSupport::Cache::MemCacheStore) end def read(name, options = {}) super(name, options.merge!(raw: true)) end def write(name, value, options = {}) super(name, value, options.merge!(raw: true)) end end end end end rack-attack-6.7.0/lib/rack/attack/store_proxy/dalli_proxy.rb0000644000004100000410000000336714537323523024166 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class DalliProxy < BaseProxy 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.7.0/lib/rack/attack/store_proxy/redis_proxy.rb0000644000004100000410000000317114537323523024200 0ustar www-datawww-data# frozen_string_literal: true require 'rack/attack/base_proxy' module Rack class Attack module StoreProxy class RedisProxy < BaseProxy 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.class == ::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 |redis| redis.incrby(key, amount) redis.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.7.0/lib/rack/attack/allow2ban.rb0000644000004100000410000000124614537323523021116 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.7.0/lib/rack/attack/check.rb0000644000004100000410000000106314537323523020307 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.7.0/lib/rack/attack.rb0000644000004100000410000000773614537323523017247 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/store_proxy/dalli_proxy' require 'rack/attack/store_proxy/mem_cache_store_proxy' require 'rack/attack/store_proxy/redis_proxy' require 'rack/attack/store_proxy/redis_store_proxy' require 'rack/attack/store_proxy/redis_cache_store_proxy' require 'rack/attack/store_proxy/active_support_redis_store_proxy' require 'rack/attack/railtie' if defined?(::Rails) module Rack class Attack class Error < StandardError; end class MisconfiguredStoreError < Error; end class MissingStoreError < Error; end class IncompatibleStoreError < Error; end autoload :Check, 'rack/attack/check' autoload :Throttle, 'rack/attack/throttle' autoload :Safelist, 'rack/attack/safelist' autoload :Blocklist, 'rack/attack/blocklist' autoload :Track, 'rack/attack/track' autoload :Fail2Ban, 'rack/attack/fail2ban' autoload :Allow2Ban, 'rack/attack/allow2ban' class << self attr_accessor :enabled, :notifier, :throttle_discriminator_normalizer 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, :throttled_responder, :throttled_responder=, :blocklisted_responder, :blocklisted_responder=, :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) @throttle_discriminator_normalizer = lambda do |discriminator| discriminator.to_s.strip.downcase end @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) # Deprecated: Keeping blocklisted_response for backwards compatibility if configuration.blocklisted_response configuration.blocklisted_response.call(env) else configuration.blocklisted_responder.call(request) end elsif configuration.throttled?(request) # Deprecated: Keeping throttled_response for backwards compatibility if configuration.throttled_response configuration.throttled_response.call(env) else configuration.throttled_responder.call(request) end else configuration.tracked?(request) @app.call(env) end end end end