diff --git a/api/.rubocop.yml b/api/.rubocop.yml index 4c53b50d80..209905970a 100644 --- a/api/.rubocop.yml +++ b/api/.rubocop.yml @@ -141,6 +141,10 @@ Lint/RedundantSafeNavigation: Lint/TrailingCommaInAttributeDeclaration: Enabled: true +Lint/UnusedBlockArgument: + Exclude: + - "config/initializers/*.rb" + Lint/UselessMethodDefinition: Enabled: true diff --git a/api/Gemfile b/api/Gemfile index b6b004e0c7..f75389612a 100644 --- a/api/Gemfile +++ b/api/Gemfile @@ -94,6 +94,7 @@ gem "premailer-rails", "~> 1.0" gem "pry-rails", "~> 0.3.9" gem "puma", "~> 6.4" gem "rack", ">= 2.0.6" +gem "rack-attack", "~> 6.7.0" gem "rack-cors", "~> 1.0" gem "rails", "~> 6.1.7.3" gem "rainbow", "~> 3.0" diff --git a/api/Gemfile.lock b/api/Gemfile.lock index 181b643979..4862094a2e 100644 --- a/api/Gemfile.lock +++ b/api/Gemfile.lock @@ -546,6 +546,8 @@ GEM nio4r (~> 2.0) racc (1.7.1) rack (2.2.8) + rack-attack (6.7.0) + rack (>= 1.0, < 4) rack-cors (1.1.1) rack (>= 2.0.0) rack-protection (2.2.4) @@ -902,6 +904,7 @@ DEPENDENCIES pry-rails (~> 0.3.9) puma (~> 6.4) rack (>= 2.0.6) + rack-attack (~> 6.7.0) rack-cors (~> 1.0) rails (~> 6.1.7.3) rainbow (~> 3.0) diff --git a/api/app/models/throttled_request.rb b/api/app/models/throttled_request.rb new file mode 100644 index 0000000000..6bff829537 --- /dev/null +++ b/api/app/models/throttled_request.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ThrottledRequest < ApplicationRecord + class << self + # @param [Rack::Request] request + # @return [void] + def track!(request) + attributes = extract_attributes_from(request) + + result = ThrottledRequest.upsert_all([attributes], unique_by: %i[ip email matched match_type path], returning: :id) + + id = result.pick("id") + + ThrottledRequest.where(id: id).update_all(occurrences: arel_table[:occurrences] + 1) + end + + private + + # @return [Hash] + def extract_attributes_from(request) + ip = request.ip + + email = AuthToken.real_email_for(request.env["HTTP_AUTHORIZATION"]) + + matched = request.env["rack.attack.matched"] + + match_type = request.env["rack.attack.match_type"] + + path = request.path + + { + ip: ip, + email: email, + matched: matched, + match_type: match_type, + path: path, + }.transform_values(&:to_s).merge(last_occurred_at: Time.current) + end + end +end diff --git a/api/config/environments/test.rb b/api/config/environments/test.rb index 88e0861663..c25e42cc9a 100644 --- a/api/config/environments/test.rb +++ b/api/config/environments/test.rb @@ -23,7 +23,7 @@ # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false - config.cache_store = :null_store + config.cache_store = :memory_store # Raise exceptions instead of rendering exception templates. config.action_dispatch.show_exceptions = true diff --git a/api/config/initializers/rack_attack.rb b/api/config/initializers/rack_attack.rb new file mode 100644 index 0000000000..808a37d6eb --- /dev/null +++ b/api/config/initializers/rack_attack.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "auth_token" + +EMPTY_PARAMS = {}.with_indifferent_access.freeze + +JSON_PARAMS_FROM = ->(request) do + params = JSON.parse(request.body) + + params.try(:with_indifferent_access) || EMPTY_PARAMS +rescue JSON::ParserError + EMPTY_PARAMS +ensure + request.body.rewind +end + +IS_COMMENT_CREATE = ->(request) do + request.post? && request.path.include?("/relationships/comments") +end + +IS_PUBLIC_ANNOTATION_CREATE = ->(request) do + return false unless request.post? && request.path.include?("/relationships/annotations") + + params = JSON_PARAMS_FROM.(request) + + params.dig("data", "attributes", "private").blank? +end + +IS_PUBLIC_RG_CREATE = ->(request) do + return false unless request.post? && request.path.start_with?("/api/v1/reading_groups") + + params = JSON_PARAMS_FROM.(request) + + params.dig("data", "attributes", "privacy") != "private" +end + +Rack::Attack.safelist("mark any admin access safe") do |request| + AuthToken.authorized_admin?(request.env["HTTP_AUTHORIZATION"]) +end + +ANN_LIMITS = { limit: 5, period: 300, }.freeze + +Rack::Attack.throttle("public annotation creation by email", **ANN_LIMITS) do |request| + AuthToken.real_email_for(request.env["HTTP_AUTHORIZATION"]) if IS_PUBLIC_ANNOTATION_CREATE.(request) +end + +Rack::Attack.throttle("public annotation creation by ip", **ANN_LIMITS) do |request| + request.ip if IS_PUBLIC_ANNOTATION_CREATE.(request) +end + +COMMENT_LIMITS = { limit: 10, period: 3600, }.freeze + +Rack::Attack.throttle("comment creation by email", **COMMENT_LIMITS) do |request| + AuthToken.real_email_for(request.env["HTTP_AUTHORIZATION"]) if IS_COMMENT_CREATE.(request) +end + +Rack::Attack.throttle("comment creation by ip", **COMMENT_LIMITS) do |request| + request.ip if IS_COMMENT_CREATE.(request) +end + +RG_LIMITS = { limit: 10, period: 3600, }.freeze + +Rack::Attack.throttle("public reading group creation by email", **RG_LIMITS) do |request| + AuthToken.real_email_for(request.env["HTTP_AUTHORIZATION"]) if IS_PUBLIC_RG_CREATE.(request) +end + +Rack::Attack.throttle("public reading group creation by ip", **RG_LIMITS) do |request| + request.ip if IS_PUBLIC_RG_CREATE.(request) +end + +Rack::Attack.blocklist("allow2ban registration by email") do |req| + params = JSON_PARAMS_FROM.(req) + + real_email = AuthToken.real_email_from(params.dig("data", "attributes", "email")) + + Rack::Attack::Allow2Ban.filter(real_email, maxretry: 5, findtime: 1.day, bantime: 1.month) do + req.path.start_with?("/api/v1/users") && req.post? + end +end + +Rack::Attack.blocklist("allow2ban registration by ip") do |req| + Rack::Attack::Allow2Ban.filter(req.ip, maxretry: 5, findtime: 1.day, bantime: 1.month) do + req.path.start_with?("/api/v1/users") && req.post? + end +end + +ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |name, start, finish, request_id, payload| + ThrottledRequest.track! payload[:request] +end + +ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, request_id, payload| + ThrottledRequest.track! payload[:request] +end + +Rack::Attack.blocklisted_responder = lambda do |request| + [503, {}, ["Internal Server Error\n"]] +end + +Rack::Attack.throttled_responder = lambda do |request| + [503, {}, ["Internal Server Error\n"]] +end diff --git a/api/db/migrate/20240220212417_create_throttled_requests.rb b/api/db/migrate/20240220212417_create_throttled_requests.rb new file mode 100644 index 0000000000..a062c68bee --- /dev/null +++ b/api/db/migrate/20240220212417_create_throttled_requests.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateThrottledRequests < ActiveRecord::Migration[6.1] + def change + create_table :throttled_requests, id: :uuid do |t| + t.inet :ip + t.citext :email + t.text :matched + t.text :match_type + t.text :path + + t.bigint :occurrences, null: false, default: 0 + + t.timestamp :last_occurred_at + + t.timestamps null: false, default: -> { "CURRENT_TIMESTAMP" } + + t.index :ip + t.index :email + + t.index %i[ip email matched match_type path], name: "index_throttled_requests_uniqueness", unique: true + end + end +end diff --git a/api/db/structure.sql b/api/db/structure.sql index e2d3ae5b07..3638c58b23 100644 --- a/api/db/structure.sql +++ b/api/db/structure.sql @@ -2791,6 +2791,24 @@ CREATE VIEW public.text_summaries AS WHERE (((c.collaboratable_type)::text = 'Text'::text) AND (c.collaboratable_id = t.id))) tm ON (true)); +-- +-- Name: throttled_requests; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.throttled_requests ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + ip inet, + email public.citext, + matched text, + match_type text, + path text, + occurrences bigint DEFAULT 0 NOT NULL, + last_occurred_at timestamp without time zone, + created_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + + -- -- Name: thumbnail_fetch_attempts; Type: TABLE; Schema: public; Owner: - -- @@ -3776,6 +3794,14 @@ ALTER TABLE ONLY public.texts ADD CONSTRAINT texts_pkey PRIMARY KEY (id); +-- +-- Name: throttled_requests throttled_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.throttled_requests + ADD CONSTRAINT throttled_requests_pkey PRIMARY KEY (id); + + -- -- Name: thumbnail_fetch_attempts thumbnail_fetch_attempts_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -5721,6 +5747,27 @@ CREATE INDEX index_texts_on_project_id ON public.texts USING btree (project_id); CREATE UNIQUE INDEX index_texts_on_slug ON public.texts USING btree (slug); +-- +-- Name: index_throttled_requests_on_email; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_throttled_requests_on_email ON public.throttled_requests USING btree (email); + + +-- +-- Name: index_throttled_requests_on_ip; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_throttled_requests_on_ip ON public.throttled_requests USING btree (ip); + + +-- +-- Name: index_throttled_requests_uniqueness; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_throttled_requests_uniqueness ON public.throttled_requests USING btree (ip, email, matched, match_type, path); + + -- -- Name: index_thumbnail_fetch_attempts_on_resource_id; Type: INDEX; Schema: public; Owner: - -- @@ -7126,6 +7173,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230921024546'), ('20231005175407'), ('20231010184158'), -('20231129172116'); +('20231129172116'), +('20240220212417'); diff --git a/api/lib/auth_token.rb b/api/lib/auth_token.rb index 62daa00cc1..9058bd7e7a 100644 --- a/api/lib/auth_token.rb +++ b/api/lib/auth_token.rb @@ -1,15 +1,86 @@ +# frozen_string_literal: true + # Responsible for encoding and decoding authentication tokens. class AuthToken - # Encode a hash in a json web token - def self.encode(payload, ttl_in_minutes = 60 * 24 * 30) - payload[:exp] = ttl_in_minutes.minutes.from_now.to_i - JWT.encode(payload, Rails.application.secrets.secret_key_base) - end + # ~ 1 month in minutes + DEFAULT_TTL = 60 * 24 * 30 + + class << self + # Encode a hash in a json web token + def encode(payload, ttl_in_minutes = DEFAULT_TTL) + payload[:exp] = ttl_in_minutes.minutes.from_now.to_i + + JWT.encode(payload, Rails.application.secrets.secret_key_base) + end + + # @param [User] user + # @return [String] + def encode_user(user) + user_id = user.id + + payload = { user_id: user_id, } + + encode(payload) + end + + # Decode a token and return the payload inside + # If will throw an error if expired or invalid. See the docs for the JWT gem. + def decode(token, leeway = nil) + payload, = JWT.decode(token, Rails.application.secrets.secret_key_base, leeway: leeway) + + payload.with_indifferent_access + end + + # @param [String, nil] header + def authorized_admin?(header) + case header + when /\ABearer (?\S+)\z/ + has_admin_privilege?(Regexp.last_match[:token]) + else + false + end + rescue JWT::DecodeError, JWT::ExpiredSignature + false + end + + # @param [String, nil] header + # @return [String, nil] + def real_email_for(header) + case header + when /\ABearer (?\S+)\z/ + fetch_real_email_for(Regexp.last_match[:token]) + end + end + + # Get the real email from a possibly suffixed email address. + # + # @param [String] email + # @return [String, nil] + def real_email_from(email) + email&.gsub(/\A([^+]+?)\+[^@]+?@/, '\1@') + end + + private + + # @param [String] token + def has_admin_privilege?(token) + user_id = decode(token).fetch(:user_id) + + Rails.cache.fetch("auth_token:admin:#{user_id}", expires_in: 1.hour) do + User.with_role(:admin).exists?(id: user_id) + end + end + + # @param [String] token + # @return [String, nil] + def fetch_real_email_for(token) + user_id = decode(token).fetch(:user_id) + + Rails.cache.fetch("auth_token:real_email:#{user_id}", expires_in: 1.hour) do + email = User.where(id: user_id).pick(:email) - # Decode a token and return the payload inside - # If will throw an error if expired or invalid. See the docs for the JWT gem. - def self.decode(token, leeway = nil) - decoded = JWT.decode(token, Rails.application.secrets.secret_key_base, leeway: leeway) - ActiveSupport::HashWithIndifferentAccess.new(decoded[0]) + AuthToken.real_email_from(email) + end + end end end diff --git a/api/spec/requests/comments_spec.rb b/api/spec/requests/comments_spec.rb index 962902dee8..8a4d74e43d 100644 --- a/api/spec/requests/comments_spec.rb +++ b/api/spec/requests/comments_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Comments API", type: :request do @@ -5,10 +7,10 @@ include_context("authenticated request") include_context("param helpers") - let(:annotation) { FactoryBot.create(:annotation) } - let(:resource) { FactoryBot.create(:resource) } - let(:comment_a) { FactoryBot.create(:comment, creator: reader, subject: annotation) } - let(:comment_b) { FactoryBot.create(:comment, creator: reader, subject: resource) } + let_it_be(:annotation, refind: true) { FactoryBot.create(:annotation) } + let_it_be(:resource, refind: true) { FactoryBot.create(:resource) } + let_it_be(:comment_a, refind: true) { FactoryBot.create(:comment, creator: reader, subject: annotation) } + let_it_be(:comment_b, refind: true) { FactoryBot.create(:comment, creator: reader, subject: resource) } context "when subject is an annotation" do describe "sends a list of comments" do @@ -94,11 +96,11 @@ describe "creates a comment" do let(:path) { api_v1_annotation_relationships_comments_path(annotation, comment_a) } - let(:params) { + let(:params) do build_json_payload(attributes: { body: "John Rambo was here.", }) - } + end context "when the user is an admin" do let(:headers) { admin_headers } @@ -107,20 +109,42 @@ api_response = JSON.parse(response.body) expect(api_response["data"]["id"]).to_not be nil end + + it "is not rate-limited" do + expect do + 12.times do + post path, headers: headers, params: params + end + end.to change(Comment, :count).by(12) + .and keep_the_same(ThrottledRequest, :count) + + expect(response).to have_http_status(201) + end end context "when the user is a reader" do let(:headers) { reader_headers } + it("returns a saved comment") do post path, headers: headers, params: params api_response = JSON.parse(response.body) expect(api_response["data"]["id"]).to_not be nil end + + it "is rate-limited" do + expect do + 12.times do + post path, headers: headers, params: params + end + end.to change(Comment, :count).by(10) + .and change(ThrottledRequest, :count).by(1) + + expect(response).to have_http_status(503) + end end end end - context "when subject is a resource" do describe "sends a list of comments" do before(:each) { get api_v1_resource_relationships_comments_path(resource), headers: reader_headers } diff --git a/api/spec/requests/reading_groups_spec.rb b/api/spec/requests/reading_groups_spec.rb index adf62d82e6..f9127c6fe9 100644 --- a/api/spec/requests/reading_groups_spec.rb +++ b/api/spec/requests/reading_groups_spec.rb @@ -129,20 +129,34 @@ end describe "creates a reading_group" do - let (:path) { api_v1_reading_groups_path } - let(:attributes) { + let(:path) { api_v1_reading_groups_path } + + let(:attributes) do { name: "My Reading Group" } - } - let(:valid_params) { + end + + let(:valid_params) do build_json_payload(attributes: attributes) - } + end it "has a 201 CREATED status code" do post path, headers: reader_headers, params: valid_params + expect(response).to have_http_status(201) end + + it "is rate-limited" do + expect do + 12.times do + post path, headers: reader_headers, params: valid_params + end + end.to change(ReadingGroup, :count).by(10) + .and change(ThrottledRequest, :count).by(1) + + expect(response).to have_http_status(503) + end end describe "deletes a reading_group" do diff --git a/api/spec/requests/text_sections/relationships/annotations_spec.rb b/api/spec/requests/text_sections/relationships/annotations_spec.rb index c61fdf2c55..98280a3b70 100644 --- a/api/spec/requests/text_sections/relationships/annotations_spec.rb +++ b/api/spec/requests/text_sections/relationships/annotations_spec.rb @@ -155,12 +155,24 @@ describe "creates an annotation" do let(:path) { api_v1_text_section_relationships_annotations_path(text_section_id: text_section.id) } - context "when the user is an reader" do - before(:each) { post path, headers: reader_headers, params: build_json_payload(annotation_params) } - describe "the response" do - it "has a 201 status code" do - expect(response).to have_http_status(201) - end + context "when the user is a reader" do + it "has a 201 status code" do + expect do + post path, headers: reader_headers, params: build_json_payload(annotation_params) + end.to change(Annotation, :count).by(1) + + expect(response).to have_http_status(201) + end + + it "is rate-limited" do + expect do + 7.times do + post path, headers: reader_headers, params: build_json_payload(annotation_params) + end + end.to change(Annotation, :count).by(5) + .and change(ThrottledRequest, :count).by(1) + + expect(response).to have_http_status(503) end end diff --git a/api/spec/requests/users_spec.rb b/api/spec/requests/users_spec.rb index ae8723e6ed..9e70f4e961 100644 --- a/api/spec/requests/users_spec.rb +++ b/api/spec/requests/users_spec.rb @@ -1,25 +1,36 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.describe "Users API", type: :request do - include_context("authenticated request") include_context("param helpers") let(:first_name) { "John" } - let(:attributes) { + + let(:attributes) do { first_name: first_name, last_name: "Higgins", + email: "jon.higgens@example.com", password: "testtest123", - email: "jon@higgins.com", password_confirmation: "testtest123", avatar: image_params, - role: "reader" + role: "reader", } - } - let(:valid_params) { + end + + let(:valid_params) do build_json_payload(attributes: attributes) - } + end + + def params_with_email_offset(offset:, attributes: self.attributes, email_base: "test.email", **options) + prefix = [email_base, offset].compact_blank.join(?+) + + attributes = attributes.merge(email: "#{prefix}@example.com") + + build_json_payload(attributes: attributes, **options) + end describe "sends a list of users" do let(:path) { api_v1_users_path } @@ -43,38 +54,79 @@ describe "creates a user" do let(:path) { api_v1_users_path } - context do - let(:api_response) { JSON.parse(response.body) } - before(:each) { allow(AccountMailer).to receive(:welcome).and_call_original } - before(:each) { post path, headers: anonymous_headers, params: valid_params } - it "sets the first name correctly" do + before do + allow(AccountMailer).to receive(:welcome).and_call_original + end + + def make_request!(headers: anonymous_headers, params: valid_params) + post path, headers: headers, params: params + end + + it "creates the user" do + expect do + make_request! + end.to change(User, :count).by(1) + + api_response = JSON.parse(response.body) + + aggregate_failures do expect(api_response["data"]["attributes"]["firstName"]).to eq(first_name) - end - it "accepts an avatar file upload and adds it to the user" do - url = api_response["data"]["attributes"]["avatarStyles"]["original"] - expect(url.blank?).to be false - end + expect(api_response["data"]["attributes"]["avatarStyles"]["original"]).to be_present - it "sends a welcome message" do expect(AccountMailer).to have_received(:welcome).once end end + it "is rate-limited" do + expect do + 7.times do |n| + make_request! params: params_with_email_offset(offset: n + 1) + end + end.to change(User, :count).by(5) + .and change(ThrottledRequest, :count).by(1) + + expect(response).to have_http_status(503) + end + it "tells the welcome mailer that the user was created by the admin when meta[createdByAdmin] is true" do valid_params = build_json_payload(attributes: attributes, meta: { created_by_admin: true }) - allow(AccountMailer).to receive(:welcome).and_call_original - post path, headers: anonymous_headers, params: valid_params + + expect do + make_request! params: valid_params + end.to change(User, :count).by(1) + expect(AccountMailer).to have_received(:welcome).with(anything, created_by_admin: true) end it "does not tell the welcome mailer that the user was created by the admin when meta[createdByAdmin] is absent" do valid_params = build_json_payload(attributes: attributes) - allow(AccountMailer).to receive(:welcome).and_call_original - post path, headers: anonymous_headers, params: valid_params - expect(AccountMailer).to have_received(:welcome).with(anything, created_by_admin: false) + + expect do + make_request! params: valid_params + end.to change(User, :count).by(1) + + expect(AccountMailer).to have_received(:welcome).with(anything, created_by_admin: false) end + context "when the ip is blocklisted directly" do + before do + Rack::Attack.blocklist_ip("127.0.0.1") + end + + after do + Rack::Attack.configuration.anonymous_blocklists.clear + end + + it "does not allow registration to occur" do + expect do + make_request! + end.to keep_the_same(User, :count) + .and change(ThrottledRequest, :count).by(1) + + expect(response).to have_http_status(503) + end + end end describe "sends a user" do diff --git a/api/spec/support/requests/authenticated_request.rb b/api/spec/support/requests/authenticated_request.rb index 6ec8d9c0f4..f208e8ad59 100644 --- a/api/spec/support/requests/authenticated_request.rb +++ b/api/spec/support/requests/authenticated_request.rb @@ -1,25 +1,10 @@ +# frozen_string_literal: true + require "rails_helper" RSpec.shared_context "authenticated request" do - def token(user, password) - params = { email: user.email, password: password } - post api_v1_tokens_path, params: params - - if response.successful? - data = JSON.parse(response.body) - - data.dig("meta", "authToken").tap do |token| - raise "Received no token: #{token}" if token.blank? - end - else - data = JSON.parse(response.body) - - errors = Array(data.dig("errors")) - - raise "Unable to receive token: #{errors.to_sentence}" - end - rescue JSON::ParserError => e - raise "Was unable to parse token: #{e.message}" + def token(user, _password) + AuthToken.encode_user(user) end def build_bearer_token(token) @@ -37,14 +22,13 @@ def get_user_token(user_type) public_send("#{user_type}_auth") end - - let(:password) { "testTest123" } - let(:anonymous_headers) { { "content-type" => "application/json" } } + let_it_be(:password) { "testTest123" } + let_it_be(:anonymous_headers) { { "content-type" => "application/json" } } RoleName.globals.each do |role| - let(:"#{role}_email") { "#{role}@castironcoding.com" } - let(role.to_sym) do - FactoryBot.create( + let_it_be(:"#{role}_email") { "#{role}@castironcoding.com" } + let_it_be(role.to_sym, refind: true) do + User.find_by(email: public_send("#{role}_email")) || FactoryBot.create( :user, role.to_sym, email: public_send("#{role}_email"), @@ -53,9 +37,9 @@ def get_user_token(user_type) ) end - let(:"#{role}_token") { token public_send(role), password } + let(:"#{role}_token") { token public_send(role), password } let(:"#{role}_headers") { build_headers public_send(:"#{role}_token") } - let(:"#{role}_auth") { build_bearer_token public_send(:"#{role}_token") } + let(:"#{role}_auth") { build_bearer_token public_send(:"#{role}_token") } end let(:another_reader_email) { "another-reader@castironcoding.com" } diff --git a/api/spec/support/requests/rate_limiting.rb b/api/spec/support/requests/rate_limiting.rb new file mode 100644 index 0000000000..5331d5a02e --- /dev/null +++ b/api/spec/support/requests/rate_limiting.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.shared_context "rack-attack rate limiting" do + after(:each) do + # Clear the rack-attack cache after every request spec. + Rack::Attack.reset! + end +end + +RSpec.configure do |config| + config.include_context "rack-attack rate limiting", type: :request +end