diff --git a/Gemfile b/Gemfile index dfea076b04736..a972ebfaf643a 100644 --- a/Gemfile +++ b/Gemfile @@ -93,6 +93,8 @@ else gem "rack", git: "https://github.com/rack/rack.git", branch: "main" end +gem "kredis", ">= 1.7.0", require: false + # Active Job group :job do gem "resque", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 082c2e8bd4183..8a9cd6a800499 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -300,6 +300,10 @@ GEM rexml kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) + kredis (1.7.0) + activemodel (>= 6.0.0) + activesupport (>= 6.0.0) + redis (>= 4.2, < 6) language_server-protocol (3.17.0.3) libxml-ruby (4.0.0) listen (3.8.0) @@ -594,6 +598,7 @@ DEPENDENCIES jbuilder jsbundling-rails json (>= 2.0.0, != 2.7.0) + kredis (>= 1.7.0) libxml-ruby listen (~> 3.3) mdl diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index d0c1b5074dd4c..cf6fb00911bfd 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,18 @@ +* Add rate limiting API using Redis and the [Kredis limiter type](https://github.com/rails/kredis/blob/main/lib/kredis/types/limiter.rb). + + ```ruby + class SessionsController < ApplicationController + rate_limit to: 10, within: 3.minutes, only: :create + end + + class SignupsController < ApplicationController + rate_limit to: 1000, within: 10.seconds, + by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups!" }, only: :new + end + ``` + + *DHH* + * Add `image/svg+xml` to the compressible content types of ActionDispatch::Static *Georg Ledermann* diff --git a/actionpack/lib/action_controller.rb b/actionpack/lib/action_controller.rb index 321aa5d0977b9..2398ae04dfe0a 100644 --- a/actionpack/lib/action_controller.rb +++ b/actionpack/lib/action_controller.rb @@ -46,6 +46,7 @@ module ActionController autoload :Logging autoload :MimeResponds autoload :ParamsWrapper + autoload :RateLimiting autoload :Redirecting autoload :Renderers autoload :Rendering diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb index 6fb0236489a2f..f5ef9ed4dc1d5 100644 --- a/actionpack/lib/action_controller/api.rb +++ b/actionpack/lib/action_controller/api.rb @@ -121,6 +121,7 @@ def self.without_modules(*modules) ConditionalGet, BasicImplicitRender, StrongParameters, + RateLimiting, DataStreaming, DefaultHeaders, diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index e2f0cbe728174..caabdd13ac1fb 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -214,6 +214,7 @@ def self.without_modules(*modules) RequestForgeryProtection, ContentSecurityPolicy, PermissionsPolicy, + RateLimiting, Streaming, DataStreaming, HttpAuthentication::Basic::ControllerMethods, diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb new file mode 100644 index 0000000000000..2427bdf2593ea --- /dev/null +++ b/actionpack/lib/action_controller/metal/rate_limiting.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module ActionController # :nodoc: + module RateLimiting + extend ActiveSupport::Concern + + module ClassMethods + # Applies a rate limit to all actions or those specified by the normal before_action filters with only: and except:. + # + # The maximum number of requests allowed is specified to: and constrained to the window of time given by within:. + # + # Rate limits are by default unique to the ip address making the request, but you can provide your own identity function by passing a callable + # in the by: parameter. It's evaluated within the context of the controller processing the request. + # + # Requests that exceed the rate limit are refused with a 429 Too Many Requests response. You can specialize this by passing a callable + # in the with: parameter. It's evaluated within the context of the controller processing the request. + # + # Examples: + # + # class SessionsController < ApplicationController + # rate_limit to: 10, within: 3.minutes, only: :create + # end + # + # class SignupsController < ApplicationController + # rate_limit to: 1000, within: 10.seconds, + # by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new + # end + # + # Note: Rate limiting relies on the application having an accessible Redis server and on Kredis 1.7.0+ being available in the bundle. + # This uses the Kredis limiter type underneath, which is failsafe, so in case Redis is inaccessible, the rate limit will not refuse action execution. + def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, **options) + require_compatible_kredis + before_action -> { rate_limiting(to: to, within: within, by: by, with: with) }, **options + end + + private + def require_compatible_kredis + require "kredis" + + if Kredis::VERSION < "1.7.0" + raise StandardError, \ + "Rate limiting requires Kredis 1.7.0+. Please update by calling `bundle update kredis`." + end + rescue LoadError + raise LoadError, \ + "Rate limiting requires Redis and Kredis. " + + "Please ensure you have Redis installed on your system and the Kredis gem in your Gemfile." + end + end + + private + def rate_limiting(to:, within:, by:, with:) + limiter = Kredis.limiter "rate-limit:#{controller_path}:#{instance_exec(&by)}", limit: to, expires_in: within + + if limiter.exceeded? + ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do + instance_exec(&with) + end + else + limiter.poke + end + end + end +end diff --git a/actionpack/test/controller/rate_limiting_test.rb b/actionpack/test/controller/rate_limiting_test.rb new file mode 100644 index 0000000000000..84b4dfbd1f3ea --- /dev/null +++ b/actionpack/test/controller/rate_limiting_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "kredis" + +Kredis.configurator = Class.new do + def config_for(name) { db: "2" } end + def root() Pathname.new(Dir.pwd) end +end.new + +# Enable Kredis logging +# ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) + +class RateLimitedController < ActionController::Base + rate_limit to: 2, within: 2.seconds, by: -> { Thread.current[:redis_test_seggregation] }, only: :limited_to_two + + def limited_to_two + head :ok + end + + rate_limit to: 2, within: 2.seconds, by: -> { Thread.current[:redis_test_seggregation] }, with: -> { head :forbidden }, only: :limited_with + def limited_with + head :ok + end +end + +class RateLimitingTest < ActionController::TestCase + tests RateLimitedController + + setup do + Thread.current[:redis_test_seggregation] = Random.hex(10) + Kredis.counter("rate-limit:rate_limited:#{Thread.current[:redis_test_seggregation]}").del + end + + test "exceeding basic limit" do + get :limited_to_two + get :limited_to_two + assert_response :ok + + get :limited_to_two + assert_response :too_many_requests + end + + test "limit resets after time" do + get :limited_to_two + get :limited_to_two + assert_response :ok + + sleep 3 + get :limited_to_two + assert_response :ok + end + + test "limited with" do + get :limited_with + get :limited_with + get :limited_with + assert_response :forbidden + end +end