Skip to content

Commit

Permalink
[F] Add rate-limiting to various endpoints in the application
Browse files Browse the repository at this point in the history
* Use memory_store cache in tests in order to test rate-limiting
* Don't do round-trip requests to get tokens. Just generate the token
  inline.
* Add temporary ThrottledRequest model to find patterns
  • Loading branch information
scryptmouse authored and zdavis committed Feb 21, 2024
1 parent 6043dea commit e893215
Show file tree
Hide file tree
Showing 15 changed files with 470 additions and 80 deletions.
4 changes: 4 additions & 0 deletions api/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ Lint/RedundantSafeNavigation:
Lint/TrailingCommaInAttributeDeclaration:
Enabled: true

Lint/UnusedBlockArgument:
Exclude:
- "config/initializers/*.rb"

Lint/UselessMethodDefinition:
Enabled: true

Expand Down
1 change: 1 addition & 0 deletions api/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions api/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions api/app/models/throttled_request.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion api/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions api/config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions api/db/migrate/20240220212417_create_throttled_requests.rb
Original file line number Diff line number Diff line change
@@ -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
50 changes: 49 additions & 1 deletion api/db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -7126,6 +7173,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20230921024546'),
('20231005175407'),
('20231010184158'),
('20231129172116');
('20231129172116'),
('20240220212417');


91 changes: 81 additions & 10 deletions api/lib/auth_token.rb
Original file line number Diff line number Diff line change
@@ -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 (?<token>\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 (?<token>\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
Loading

0 comments on commit e893215

Please sign in to comment.