-
Notifications
You must be signed in to change notification settings - Fork 31
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[F] Add rate-limiting to various endpoints in the application
* 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
1 parent
6043dea
commit e893215
Showing
15 changed files
with
470 additions
and
80 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
24
api/db/migrate/20240220212417_create_throttled_requests.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.