diff --git a/Gemfile b/Gemfile index bee303c..ad61144 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ gem "sprockets", "~> 3.7" gem "activejob-uniqueness", require: "active_job/uniqueness/sidekiq_patch" gem "fog-aws" +gem "rack-attack", "~> 6.6" gem "sys-filesystem" group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index f10df1f..f365ba4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -675,8 +675,8 @@ GEM nio4r (~> 2.0) raabro (1.4.0) racc (1.5.2) - rack (2.2.3) - rack-attack (6.5.0) + rack (2.2.6.4) + rack-attack (6.6.1) rack (>= 1.0, < 3) rack-cors (1.1.1) rack (>= 2.0.0) @@ -953,6 +953,7 @@ DEPENDENCIES newrelic_rpm passenger puma (~> 5.3.1) + rack-attack (~> 6.6) ruby-progressbar sendgrid-ruby sentry-rails diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 738e5f4..8f5c022 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -107,5 +107,5 @@ ignore_unused: - decidim.anonymous_proposals.{new_session,register,shared.*} - decidim.components.proposals.settings.global.anonymous_proposals_enabled - decidim.components.anonymous_proposals.name - + - rack_attack.too_many_requests.{message,time,time_unit,title} diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index a6ccd9f..f006102 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -1,12 +1,150 @@ # frozen_string_literal: true -if Rails.env.production? || Rails.env.test? - require "rack/attack" +# Enabled by default in production +# Can be deactivated with 'ENABLE_RACK_ATTACK=0' +return if Rails.application.secrets.dig(:decidim, :rack_attack, :enabled).zero? - class Rack::Attack - throttle("req/ip", limit: 100, period: 1.minute) do |req| - Rails.logger.warn("[Rack::Attack] [THROTTLE - req / ip] :: #{req.ip} :: #{req.path} :: #{req.GET}") - req.ip unless req.path.start_with?("/assets") +Rack::Attack.enabled = (Rails.application.secrets.dig(:decidim, :rack_attack, :enabled) == 1) || Rails.env.production? +Rack::Attack.throttled_response_retry_after_header = true + +# By default use the memory store for inspecting requests +# Better to use MemCached or Redis in production mode +Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new if !ENV["MEMCACHEDCLOUD_SERVERS"] || Rails.env.test? + +# Remove the original throttle fron decidim-core +# see https://github.com/decidim/decidim/blob/release/0.26-stable/decidim-core/config/initializers/rack_attack.rb#L19 +Rails.application.config.after_initialize do + Rack::Attack.throttles.delete("requests by ip") +end + +Rack::Attack.throttled_responder = lambda do |request| + rack_logger = Logger.new(Rails.root.join("log/rack_attack.log")) + match_data = request.env["rack.attack.match_data"] + now = match_data[:epoch_time] + limit = now + (match_data[:period] - now % match_data[:period]) + + request_uuid = request.env["action_dispatch.request_id"] + params = { + "ip" => request.ip, + "path" => request.path, + "get" => request.GET, + "host" => request.host, + "referer" => request.referer + } + + rack_logger.warn("[#{request_uuid}] #{params}") + + [429, { "Content-Type" => "text/html" }, [html_template(limit - now, request.env["decidim.current_organization"]&.name)]] +end + +Rack::Attack.throttle("req/ip", + limit: Rails.application.secrets.dig(:decidim, :rack_attack, :throttle, :max_requests), + period: Rails.application.secrets.dig(:decidim, :rack_attack, :throttle, :period)) do |req| + req.ip unless req.path.start_with?("/decidim-packs") || req.path.start_with?("/rails/active_storage") || req.path.start_with?("/admin/") +end + +if Rails.application.secrets.dig(:decidim, :rack_attack, :fail2ban, :enabled) == 1 + # Block suspicious requests made for pentesting + # After 1 forbidden request, block all requests from that IP for 1 hour. + 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: 0, findtime: 10.minutes, bantime: 1.hour) do + # The count for the IP is incremented if the return value is truthy + req.url.include?("etc/passwd") || + req.url.include?("wp-admin") || + req.url.include?("wp-login") || + req.url.include?("SELECT") || + req.url.include?("CONCAT") || + req.url.include?("UNION%20SELECT") || + req.url.include?(".git") end end end + +def html_template(until_period, organization_name) + name = organization_name.presence || "our platform" + + " + + +
+#{I18n.t("rack_attack.too_many_requests.message")}
+ #{I18n.t("rack_attack.too_many_requests.time")} +