mirror of
https://github.com/discourse/discourse.git
synced 2024-12-18 12:23:46 +08:00
2686d14b9a
Previous to this change our anonymous rate limits acted as a throttle. New implementation means we now also consider rate limited requests towards the limit. This means that if an anonymous user is hammering the server it will not be able to get any requests through until it subsides with traffic.
206 lines
4.3 KiB
Ruby
206 lines
4.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A redis backed rate limiter.
|
|
class RateLimiter
|
|
|
|
attr_reader :max, :secs, :user, :key
|
|
|
|
def self.key_prefix
|
|
"l-rate-limit3:"
|
|
end
|
|
|
|
def self.disable
|
|
@disabled = true
|
|
end
|
|
|
|
def self.enable
|
|
@disabled = false
|
|
end
|
|
|
|
# We don't observe rate limits in test mode
|
|
def self.disabled?
|
|
@disabled
|
|
end
|
|
|
|
# Only used in test, only clears current namespace, does not clear globals
|
|
def self.clear_all!
|
|
Discourse.redis.delete_prefixed(RateLimiter.key_prefix)
|
|
end
|
|
|
|
def self.clear_all_global!
|
|
Discourse.redis.without_namespace.keys("GLOBAL::#{key_prefix}*").each do |k|
|
|
Discourse.redis.without_namespace.del k
|
|
end
|
|
end
|
|
|
|
def build_key(type)
|
|
"#{RateLimiter.key_prefix}:#{@user && @user.id}:#{type}"
|
|
end
|
|
|
|
def initialize(user, type, max, secs, global: false, aggressive: false)
|
|
@user = user
|
|
@type = type
|
|
@key = build_key(type)
|
|
@max = max
|
|
@secs = secs
|
|
@global = global
|
|
@aggressive = aggressive
|
|
end
|
|
|
|
def clear!
|
|
redis.del(prefixed_key)
|
|
end
|
|
|
|
def can_perform?
|
|
rate_unlimited? || is_under_limit?
|
|
end
|
|
|
|
# reloader friendly
|
|
unless defined? PERFORM_LUA
|
|
PERFORM_LUA = <<~LUA
|
|
local now = tonumber(ARGV[1])
|
|
local secs = tonumber(ARGV[2])
|
|
local max = tonumber(ARGV[3])
|
|
|
|
local key = KEYS[1]
|
|
|
|
|
|
if ((tonumber(redis.call("LLEN", key)) < max) or
|
|
(now - tonumber(redis.call("LRANGE", key, -1, -1)[1])) > secs) then
|
|
redis.call("LPUSH", key, now)
|
|
redis.call("LTRIM", key, 0, max - 1)
|
|
redis.call("EXPIRE", key, secs * 2)
|
|
|
|
return 1
|
|
else
|
|
return 0
|
|
end
|
|
LUA
|
|
|
|
PERFORM_LUA_SHA = Digest::SHA1.hexdigest(PERFORM_LUA)
|
|
end
|
|
|
|
unless defined? PERFORM_LUA_AGGRESSIVE
|
|
PERFORM_LUA_AGGRESSIVE = <<~LUA
|
|
local now = tonumber(ARGV[1])
|
|
local secs = tonumber(ARGV[2])
|
|
local max = tonumber(ARGV[3])
|
|
|
|
local key = KEYS[1]
|
|
|
|
local return_val = 0
|
|
|
|
if ((tonumber(redis.call("LLEN", key)) < max) or
|
|
(now - tonumber(redis.call("LRANGE", key, -1, -1)[1])) > secs) then
|
|
return_val = 1
|
|
else
|
|
return_val = 0
|
|
end
|
|
|
|
redis.call("LPUSH", key, now)
|
|
redis.call("LTRIM", key, 0, max - 1)
|
|
redis.call("EXPIRE", key, secs * 2)
|
|
|
|
return return_val
|
|
LUA
|
|
|
|
PERFORM_LUA_AGGRESSIVE_SHA = Digest::SHA1.hexdigest(PERFORM_LUA_AGGRESSIVE)
|
|
end
|
|
|
|
def performed!(raise_error: true)
|
|
return true if rate_unlimited?
|
|
now = Time.now.to_i
|
|
|
|
if ((max || 0) <= 0) || rate_limiter_allowed?(now)
|
|
raise RateLimiter::LimitExceeded.new(seconds_to_wait, @type) if raise_error
|
|
false
|
|
else
|
|
true
|
|
end
|
|
rescue Redis::CommandError => e
|
|
if e.message =~ /READONLY/
|
|
# TODO,switch to in-memory rate limiter
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
|
|
def rollback!
|
|
return if RateLimiter.disabled?
|
|
redis.lpop(prefixed_key)
|
|
rescue Redis::CommandError => e
|
|
if e.message =~ /READONLY/
|
|
# TODO,switch to in-memory rate limiter
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
|
|
def remaining
|
|
return @max if @user && @user.staff?
|
|
|
|
arr = redis.lrange(prefixed_key, 0, @max) || []
|
|
t0 = Time.now.to_i
|
|
arr.reject! { |a| (t0 - a.to_i) > @secs }
|
|
@max - arr.size
|
|
end
|
|
|
|
private
|
|
|
|
def rate_limiter_allowed?(now)
|
|
|
|
lua, lua_sha = nil
|
|
if @aggressive
|
|
lua = PERFORM_LUA_AGGRESSIVE
|
|
lua_sha = PERFORM_LUA_AGGRESSIVE_SHA
|
|
else
|
|
lua = PERFORM_LUA
|
|
lua_sha = PERFORM_LUA_SHA
|
|
end
|
|
|
|
eval_lua(lua, lua_sha, [prefixed_key], [now, @secs, @max]) == 0
|
|
end
|
|
|
|
def prefixed_key
|
|
if @global
|
|
"GLOBAL::#{key}"
|
|
else
|
|
Discourse.redis.namespace_key(key)
|
|
end
|
|
end
|
|
|
|
def redis
|
|
Discourse.redis.without_namespace
|
|
end
|
|
|
|
def seconds_to_wait
|
|
@secs - age_of_oldest
|
|
end
|
|
|
|
def age_of_oldest
|
|
# age of oldest event in buffer, in seconds
|
|
Time.now.to_i - redis.lrange(prefixed_key, -1, -1).first.to_i
|
|
end
|
|
|
|
def is_under_limit?
|
|
# number of events in buffer less than max allowed? OR
|
|
(redis.llen(prefixed_key) < @max) ||
|
|
# age bigger than silding window size?
|
|
(age_of_oldest > @secs)
|
|
end
|
|
|
|
def rate_unlimited?
|
|
!!(RateLimiter.disabled? || (@user && @user.staff?))
|
|
end
|
|
|
|
def eval_lua(lua, sha, keys, args)
|
|
redis.evalsha(sha, keys, args)
|
|
rescue Redis::CommandError => e
|
|
if e.to_s =~ /^NOSCRIPT/
|
|
redis.eval(lua, keys, args)
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
end
|