diff --git a/Gemfile b/Gemfile index 2a355dbc3..af14cb3d5 100644 --- a/Gemfile +++ b/Gemfile @@ -65,6 +65,8 @@ gem "fake_email_validator" gem "jwt", "~> 2.4" +gem "rack-attack" + group :development do gem "binding_of_caller" gem "byebug" diff --git a/Gemfile.lock b/Gemfile.lock index b8fda2c58..7415b989c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -350,6 +350,8 @@ GEM questiongenerator (1.0.0) racc (1.6.0) rack (2.2.4) + rack-attack (6.6.1) + rack (>= 1.0, < 3) rack-protection (2.2.0) rack rack-proxy (0.7.2) @@ -610,6 +612,7 @@ DEPENDENCIES poltergeist puma questiongenerator (~> 1.0) + rack-attack rails (~> 6.1) rails-controller-testing rails-i18n (~> 6.0) diff --git a/config/application.rb b/config/application.rb index d62c81762..7b42b0c71 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,6 +22,8 @@ class Application < Rails::Application # Use Sidekiq for background jobs config.active_job.queue_adapter = :sidekiq + config.middleware.use Rack::Attack + config.i18n.default_locale = "en" config.i18n.fallbacks = [I18n.default_locale] config.i18n.enforce_available_locales = false diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 000000000..5d9d072d4 --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Rack::Attack + class Request < ::Rack::Request + def authenticated_user_id + @env.dig("rack.session", "warden.user.user.key", 0, 0) + end + + def unauthenticated? + !authenticated_user_id + end + + def remote_ip + @remote_ip ||= (@env["action_dispatch.remote_ip"] || ip).to_s + end + end + + throttle("throttle_unauthenticated_asking", limit: 3, period: 1.minutes) do |req| + req.remote_ip if req.path == "/ajax/ask" && req.unauthenticated? + end + + throttle("throttle_authenticated_asking", limit: 30, period: 15.minutes) do |req| + req.authenticated_user_id if req.path == "/ajax/ask" && !req.unauthenticated? + end +end diff --git a/spec/requests/rack_attack_spec.rb b/spec/requests/rack_attack_spec.rb new file mode 100644 index 000000000..8ac742b55 --- /dev/null +++ b/spec/requests/rack_attack_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Rack::Attack", type: :request do + include ActiveSupport::Testing::TimeHelpers + include Warden::Test::Helpers + + before(:each) do + Rack::Attack.enabled = true + Rack::Attack.reset! + end + + describe "throttle_unauthenticated_asking" do + it "should throttle unauthenticated users" do + 3.times do + post "/ajax/ask" + expect(response.status).to eq(200) + end + + post "/ajax/ask" + expect(response.status).to eq(429) + end + + it "should unthrottle after the given period" do + 3.times do + post "/ajax/ask" + expect(response.status).to eq(200) + end + + post "/ajax/ask" + expect(response.status).to eq(429) + + travel_to(5.minutes.from_now) do + post "/ajax/ask" + expect(response.status).to eq(200) + end + end + end + + describe "throttle_authenticated_asking" do + let (:user) { FactoryBot.create(:user) } + + it "should throttle authenticated users" do + login_as user, scope: :user + + 30.times do + post "/ajax/ask" + expect(response.status).to eq(200) + end + + post "/ajax/ask" + expect(response.status).to eq(429) + end + + it "should unthrottle after the given period" do + login_as user, scope: :user + + 30.times do + post "/ajax/ask" + expect(response.status).to eq(200) + end + + post "/ajax/ask" + expect(response.status).to eq(429) + + travel_to(20.minutes.from_now) do + post "/ajax/ask" + expect(response.status).to eq(200) + end + end + end +end