diff --git a/Gemfile b/Gemfile
index dfea076b047..a972ebfaf64 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 082c2e8bd41..8a9cd6a8004 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 d0c1b5074dd..cf6fb00911b 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 321aa5d0977..2398ae04dfe 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 6fb0236489a..f5ef9ed4dc1 100644
--- a/actionpack/lib/action_controller/api.rb
+++ b/actionpack/lib/action_controller/api.rb
@@ -121,6 +121,7 @@ module ActionController
ConditionalGet,
BasicImplicitRender,
StrongParameters,
+ RateLimiting,
DataStreaming,
DefaultHeaders,
diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb
index e2f0cbe7281..caabdd13ac1 100644
--- a/actionpack/lib/action_controller/base.rb
+++ b/actionpack/lib/action_controller/base.rb
@@ -214,6 +214,7 @@ module ActionController
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 00000000000..2427bdf2593
--- /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 00000000000..84b4dfbd1f3
--- /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