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" + + " + + + + Too many requests + + + + +
+
+ #{I18n.t("rack_attack.too_many_requests.title", organization_name: name)} +
+

429 - Too many requests

+

#{I18n.t("rack_attack.too_many_requests.message")}

+ #{I18n.t("rack_attack.too_many_requests.time")} +
+ #{until_period} #{I18n.t("rack_attack.too_many_requests.time_unit")} +
+
+ + + +" +end diff --git a/config/locales/de.yml b/config/locales/de.yml index a8f513b..0548956 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -38,7 +38,7 @@ de: newsletter: Gelegentlich einen Newsletter mit relevanten Informationen erhalten newsletter_title: Kontakterlaubnis nickname_help: Ihr Pseudonym in %{organization}. Kann nur Buchstaben, Zahlen, '-' und '_' enthalten. - password_help: "Mindestens %{minimun_characters} Zeichen, nicht zu gewöhnlich (z.B. 123456) und darf nicht Ihr Benutzername oder Ihre E-Mail-Adresse sein." + password_help: Mindestens %{minimun_characters} Zeichen, nicht zu gewöhnlich (z.B. 123456) und darf nicht Ihr Benutzername oder Ihre E-Mail-Adresse sein. postal_code: Postleitzahl sign_in: Anmelden sign_up: Registrieren diff --git a/config/locales/en.yml b/config/locales/en.yml index 5de4847..3654e4f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -86,3 +86,9 @@ en: user_menu: conversations: Conversations notifications: Notifications + rack_attack: + too_many_requests: + message: Your connection has been slowed because server received too many requests. + time: 'You will be able to navigate on our website in :' + time_unit: seconds + title: Thank you for your participation on %{organization_name} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 9c1ecd0..23977c1 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -115,3 +115,9 @@ fr: user_menu: conversations: Conversations notifications: Notifications + rack_attack: + too_many_requests: + message: Il semblerait que vous fassiez trop de requetes sur notre serveur, votre connexion a ete ralentie. + time: 'Vous pourrez naviguer de nouveau sur notre plateforme dans :' + time_unit: secondes + title: Merci pour votre participation sur %{organization_name} diff --git a/config/secrets.yml b/config/secrets.yml index e68e4e4..009ac77 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -12,6 +12,14 @@ default: &default asset_host: <%= ENV["ASSET_HOST"] %> + decidim: + rack_attack: + enabled: <%= ENV["ENABLE_RACK_ATTACK"]&.to_i || 1 %> + fail2ban: + enabled: <%= ENV["RACK_ATTACK_FAIL2BAN"]&.to_i || 1 %> + throttle: + max_requests: <%= ENV["THROTTLING_MAX_REQUESTS"]&.to_i || 100 %> + period: <%= ENV["THROTTLING_PERIOD"]&.to_i || 60 %> scaleway: id: <%= ENV["SCALEWAY_ID"] %> token: <%= ENV["SCALEWAY_TOKEN"] %> diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 15e3153..981021a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,5 +23,7 @@ SocialShareButton.configure do |social_share_button| social_share_button.allow_sites = %w(twitter facebook whatsapp_app whatsapp_web telegram) end + + allow(Rack::Attack).to receive(:enabled).and_return(false) end end