Skip to content

Commit

Permalink
Replace rack-attack with built-in rate-limiting.
Browse files Browse the repository at this point in the history
  • Loading branch information
tristandunn committed Sep 7, 2024
1 parent a92f45e commit 19bba76
Show file tree
Hide file tree
Showing 9 changed files with 64 additions and 204 deletions.
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ gem "oj", "3.16.5"
gem "pg", "1.5.7"
gem "propshaft", "0.9.1"
gem "puma", "6.4.2"
gem "rack-attack", "6.7.0"
gem "rack-timeout", "0.7.0"
gem "rails", "7.2.1"
gem "redis", "5.3.0"
Expand Down
3 changes: 0 additions & 3 deletions Gemfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
class AccountsController < ApplicationController
before_action :redirect_authenticated_account, if: :signed_in?

rate_limit to: 5, within: 1.hour, only: :create

# Render the new account form.
def new
@form = AccountForm.new
Expand Down
34 changes: 31 additions & 3 deletions app/controllers/commands_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

class CommandsController < ApplicationController
before_action :require_active_character
before_action :require_active_character, :rate_limit_command
after_action :mark_character_as_active, if: :mark_character_as_active?

rescue_from ActionController::ParameterMissing do
Expand All @@ -10,8 +10,6 @@ class CommandsController < ApplicationController

# Execute a user command.
def create
command = Command.call(input.squish, character: current_character)

if command.rendered?
render command.render_options
else
Expand All @@ -21,6 +19,13 @@ def create

private

# Return an instance of the command being created.
#
# @return [Commands::Base] The command being created.
def command
@command ||= Command.call(input.squish, character: current_character)
end

# Return the command input.
#
# @return [String]
Expand All @@ -41,4 +46,27 @@ def mark_character_as_active
def mark_character_as_active?
current_character.active_at <= 30.seconds.ago
end

# Run rate limiting for the command being created.
#
# @return [void]
def rate_limit_command
rate_limiting(
to: command.class.limit,
within: command.class.period,
with: -> { head :too_many_requests },
store: cache_store,
by: -> { rate_limit_identifier }
)
end

# Return the rate limit identifier for the command being created.
#
# @return [String]
def rate_limit_identifier
[
"account-#{current_character.account_id}",
"command-#{command.class.name.delete_prefix("Commands::").underscore}"
].join(":")
end
end
3 changes: 3 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ class SessionsController < ApplicationController
except: :destroy,
if: :signed_in?

rate_limit to: 1, within: 1.second, only: :create
rate_limit to: 5, within: 30.seconds, only: :create, by: -> { session_parameters[:email] }

# Display the session form.
def new
@form = SessionForm.new
Expand Down
45 changes: 0 additions & 45 deletions config/initializers/throttling.rb

This file was deleted.

31 changes: 28 additions & 3 deletions spec/controllers/commands_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,36 @@
context "when created successfully" do
let(:character) { create(:character) }
let(:clean_input) { "Hello, world!" }
let(:raw_input) { " Hello, world! " }
let(:limit) { 1 }

let(:command) do
instance_double(Commands::Unknown, rendered?: false)
instance_double(Commands::Unknown, class: Commands::Unknown, rendered?: false)
end

before do
allow(Command).to receive(:call).and_return(command)
allow(controller).to receive(:rate_limiting).and_call_original
allow(controller.cache_store).to receive(:increment).and_return(limit)

sign_in_as character

post :create, params: { input: raw_input }, format: :turbo_stream
post :create, params: { input: " Hello, world! " }, format: :turbo_stream
end

context "with rendering" do
let(:command) do
instance_double(
Commands::Unknown,
class: command_class,
rendered?: true,
render_options: {
partial: "commands/unknown/success"
}
)
end
let(:command_class) do
class_double(Commands::Unknown, name: "Commands::Unknown", limit: 13, period: 37.seconds)
end

it { is_expected.to respond_with(200) }
it { is_expected.to render_template("commands/unknown/_success") }
Expand All @@ -39,6 +45,25 @@
expect(Command).to have_received(:call)
.with(clean_input, character: character)
end

it "enforces rate limiting" do
expect(controller).to have_received(:rate_limiting)
.with(hash_including(to: command_class.limit, within: command_class.period))
end

it "tracks rate limiting by account and command" do
expect(controller.cache_store).to have_received(:increment).with(
"rate-limit:commands:account-#{character.account_id}:command-unknown",
1,
expires_in: command_class.period
)
end
end

context "when rate limited" do
let(:limit) { 999 }

it { is_expected.to respond_with(429) }
end

context "without rendering" do
Expand Down
146 changes: 0 additions & 146 deletions spec/lib/rack/attack_spec.rb

This file was deleted.

3 changes: 0 additions & 3 deletions spec/support/rack_attack.rb

This file was deleted.

0 comments on commit 19bba76

Please sign in to comment.