diff --git a/app/assets/javascripts/discourse/components/global-notice.js.es6 b/app/assets/javascripts/discourse/components/global-notice.js.es6 index 06f7e68a166..9ea7f4104bd 100644 --- a/app/assets/javascripts/discourse/components/global-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/global-notice.js.es6 @@ -9,6 +9,11 @@ export default Ember.Component.extend(bufferedRender({ buildBuffer(buffer) { let notices = []; + if ($.cookie("dosp") === "1") { + $.cookie("dosp", null, { path: '/' }); + notices.push([I18n.t("forced_anonymous"), 'forced-anonymous']); + } + if (this.session.get('safe_mode')) { notices.push([I18n.t("safe_mode.enabled"), 'safe-mode']); } diff --git a/config/discourse_defaults.conf b/config/discourse_defaults.conf index 6e3cf55ed07..9c3cf3c38a7 100644 --- a/config/discourse_defaults.conf +++ b/config/discourse_defaults.conf @@ -194,3 +194,11 @@ max_reqs_per_ip_mode = none # bypass rate limiting any IP resolved as a private IP 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 + diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 829650b154a..5498ba32e70 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2690,6 +2690,8 @@ en: custom_message_template_forum: "Hey, you should join this forum!" 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: enabled: "Safe mode is enabled, to exit safe mode close this browser window" diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index ad5f0eeff20..217f294a85e 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_dependency "mobile_detection" require_dependency "crawler_detection" require_dependency "guardian" @@ -10,9 +12,9 @@ module Middleware end class Helper - USER_AGENT = "HTTP_USER_AGENT".freeze - RACK_SESSION = "rack.session".freeze - ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING".freeze + USER_AGENT = "HTTP_USER_AGENT" + RACK_SESSION = "rack.session" + ACCEPT_ENCODING = "HTTP_ACCEPT_ENCODING" def initialize(env) @env = env @@ -86,7 +88,40 @@ module Middleware def no_cache_bypass 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 def cacheable? @@ -142,13 +177,26 @@ module Middleware def call(env) helper = Helper.new(env) + force_anon = false - if helper.cacheable? - helper.cached || helper.cache(@app.call(env)) - else - @app.call(env) + if helper.should_force_anonymous? + force_anon = env["DISCOURSE_FORCE_ANON"] = true + helper.force_anonymous! 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 diff --git a/lib/rate_limiter.rb b/lib/rate_limiter.rb index 8645bb003d8..2582b9587a8 100644 --- a/lib/rate_limiter.rb +++ b/lib/rate_limiter.rb @@ -80,14 +80,17 @@ class RateLimiter PERFORM_LUA_SHA = Digest::SHA1.hexdigest(PERFORM_LUA) end - def performed! + def performed!(raise_error: true) return if rate_unlimited? now = Time.now.to_i if ((max || 0) <= 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 rescue Redis::CommandError => e if e.message =~ /READONLY/ diff --git a/spec/components/middleware/anonymous_cache_spec.rb b/spec/components/middleware/anonymous_cache_spec.rb index 50d608c8708..800f4d825ad 100644 --- a/spec/components/middleware/anonymous_cache_spec.rb +++ b/spec/components/middleware/anonymous_cache_spec.rb @@ -45,6 +45,55 @@ describe Middleware::AnonymousCache::Helper do 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 let!(:helper) do new_helper("ANON_CACHE_DURATION" => 10)