FEATURE: if site is under extreme load show anon view

If a particular path is being hit extremely hard by logged on users,
revert to anonymous cached view.

This will only come into effect if 3 requests queue for longer than 2 seconds
on a *single* path.

This can happen if a URL is shared with the entire forum base and everyone
is logged on
This commit is contained in:
Sam 2018-04-18 16:58:40 +10:00
parent 7bf9650e96
commit 59cd7894d9
6 changed files with 125 additions and 10 deletions

View File

@ -9,6 +9,11 @@ export default Ember.Component.extend(bufferedRender({
buildBuffer(buffer) { buildBuffer(buffer) {
let notices = []; let notices = [];
if ($.cookie("dosp") === "1") {
$.cookie("dosp", null, { path: '/' });
notices.push([I18n.t("forced_anonymous"), 'forced-anonymous']);
}
if (this.session.get('safe_mode')) { if (this.session.get('safe_mode')) {
notices.push([I18n.t("safe_mode.enabled"), 'safe-mode']); notices.push([I18n.t("safe_mode.enabled"), 'safe-mode']);
} }

View File

@ -194,3 +194,11 @@ max_reqs_per_ip_mode = none
# bypass rate limiting any IP resolved as a private IP # bypass rate limiting any IP resolved as a private IP
max_reqs_rate_limit_on_private = false max_reqs_rate_limit_on_private = false
# logged in DoS protection
# protection will only trigger for requests that queue longer than this amount
force_anonymous_min_queue_seconds = 2
# only trigger anon if we see more than N requests for this path in last 10 seconds
force_anonymous_min_per_10_seconds = 3

View File

@ -2690,6 +2690,8 @@ en:
custom_message_template_forum: "Hey, you should join this forum!" custom_message_template_forum: "Hey, you should join this forum!"
custom_message_template_topic: "Hey, I thought you might enjoy this topic!" custom_message_template_topic: "Hey, I thought you might enjoy this topic!"
forced_anonymous: "Site is under heavy load, we are temporarily presenting you with a cached anonymous view"
safe_mode: safe_mode:
enabled: "Safe mode is enabled, to exit safe mode close this browser window" enabled: "Safe mode is enabled, to exit safe mode close this browser window"

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
require_dependency "mobile_detection" require_dependency "mobile_detection"
require_dependency "crawler_detection" require_dependency "crawler_detection"
require_dependency "guardian" require_dependency "guardian"
@ -10,9 +12,9 @@ module Middleware
end end
class Helper class Helper
USER_AGENT = "HTTP_USER_AGENT".freeze USER_AGENT = "HTTP_USER_AGENT"
RACK_SESSION = "rack.session".freeze RACK_SESSION = "rack.session"
ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING".freeze ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING"
def initialize(env) def initialize(env)
@env = env @env = env
@ -86,7 +88,40 @@ module Middleware
def no_cache_bypass def no_cache_bypass
request = Rack::Request.new(@env) request = Rack::Request.new(@env)
request.cookies['_bypass_cache'].nil? request.cookies['_bypass_cache'].nil? &&
request[Auth::DefaultCurrentUserProvider::API_KEY].nil? &&
@env[Auth::DefaultCurrentUserProvider::USER_API_KEY].nil?
end
def force_anonymous!
@env[Auth::DefaultCurrentUserProvider::USER_API_KEY] = nil
@env['HTTP_COOKIE'] = nil
@env['rack.request.cookie.hash'] = {}
@env['rack.request.cookie.string'] = ''
@env['_bypass_cache'] = nil
request = Rack::Request.new(@env)
request.delete_param('api_username')
request.delete_param('api_key')
end
def check_logged_in_rate_limit!
limiter = RateLimiter.new(
nil,
"logged_in_anon_cache_#{@env["HOST"]}/#{@env["REQUEST_URI"]}",
GlobalSetting.force_anonymous_min_per_10_seconds,
10
)
!limiter.performed!(raise_error: false)
end
def should_force_anonymous?
if queue_time = @env['REQUEST_QUEUE_SECONDS']
if queue_time > GlobalSetting.force_anonymous_min_queue_seconds && get?
return check_logged_in_rate_limit!
end
end
false
end end
def cacheable? def cacheable?
@ -142,13 +177,26 @@ module Middleware
def call(env) def call(env)
helper = Helper.new(env) helper = Helper.new(env)
force_anon = false
if helper.cacheable? if helper.should_force_anonymous?
helper.cached || helper.cache(@app.call(env)) force_anon = env["DISCOURSE_FORCE_ANON"] = true
else helper.force_anonymous!
@app.call(env)
end end
result =
if helper.cacheable?
helper.cached || helper.cache(@app.call(env))
else
@app.call(env)
end
if force_anon
result[1]["Set-Cookie"] = "dosp=1"
end
result
end end
end end

View File

@ -80,14 +80,17 @@ class RateLimiter
PERFORM_LUA_SHA = Digest::SHA1.hexdigest(PERFORM_LUA) PERFORM_LUA_SHA = Digest::SHA1.hexdigest(PERFORM_LUA)
end end
def performed! def performed!(raise_error: true)
return if rate_unlimited? return if rate_unlimited?
now = Time.now.to_i now = Time.now.to_i
if ((max || 0) <= 0) || if ((max || 0) <= 0) ||
(eval_lua(PERFORM_LUA, PERFORM_LUA_SHA, [prefixed_key], [now, @secs, @max]) == 0) (eval_lua(PERFORM_LUA, PERFORM_LUA_SHA, [prefixed_key], [now, @secs, @max]) == 0)
raise RateLimiter::LimitExceeded.new(seconds_to_wait, @type) raise RateLimiter::LimitExceeded.new(seconds_to_wait, @type) if raise_error
false
else
true
end end
rescue Redis::CommandError => e rescue Redis::CommandError => e
if e.message =~ /READONLY/ if e.message =~ /READONLY/

View File

@ -45,6 +45,55 @@ describe Middleware::AnonymousCache::Helper do
end end
end end
context 'force_anonymous!' do
before do
RateLimiter.enable
end
after do
RateLimiter.disable
end
it 'will revert to anonymous once we reach the limit' do
RateLimiter.clear_all!
is_anon = false
app = Middleware::AnonymousCache.new(
lambda do |env|
is_anon = env["HTTP_COOKIE"].nil?
[200, {}, ["ok"]]
end
)
global_setting :force_anonymous_min_per_10_seconds, 2
global_setting :force_anonymous_min_queue_seconds, 1
env = {
"HTTP_COOKIE" => "_t=#{SecureRandom.hex}",
"HOST" => "site.com",
"REQUEST_METHOD" => "GET",
"REQUEST_URI" => "/somewhere/rainbow",
"REQUEST_QUEUE_SECONDS" => 2.1,
"rack.input" => StringIO.new
}
app.call(env)
expect(is_anon).to eq(false)
app.call(env)
expect(is_anon).to eq(false)
app.call(env)
expect(is_anon).to eq(true)
_status, headers, _body = app.call(env)
expect(is_anon).to eq(true)
expect(headers['Set-Cookie']).to eq('dosp=1')
end
end
context "cached" do context "cached" do
let!(:helper) do let!(:helper) do
new_helper("ANON_CACHE_DURATION" => 10) new_helper("ANON_CACHE_DURATION" => 10)