Add rack-attack throttler for all logins

We have a built-in bruteforce protection for built-in users. When users
are being created from LDAP on-the-fly, these limits cannot apply, as we
do not have a user object yet.

Instead, we can provide a more generous throttler to block attempts
This commit is contained in:
Oliver Günther
2026-05-29 09:07:41 +02:00
parent 33198e8d68
commit b5350cccf7
6 changed files with 217 additions and 18 deletions
+17 -10
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module OpenProject
module RateLimiting
module_function
@@ -9,13 +11,15 @@ module OpenProject
def default_rules
@default_rules ||= [
LostPassword,
APIV3
APIV3,
Login
]
end
def set_defaults!
Rack::Attack.clear_configuration
Rack::Attack.throttled_responder = ->(request) { OpenProject::RateLimiting.throttled_response(request) }
Rack::Attack.blocklisted_responder = ->(request) { OpenProject::RateLimiting.blocklisted_response(request) }
@active_rules = []
default_rules.each do |rule|
@@ -31,17 +35,20 @@ module OpenProject
active_rules << rule.new.apply! if rule.enabled?
end
##
# Try to find a matching rule to respond with
# or use the default responder
def throttled_response(request)
rule = active_rules.find { |r| r.rule_name == request.env["rack.attack.matched"] }
matched = request.env["rack.attack.matched"]
rule = find_rule(matched)
rule ? rule.response(request) : Base.new.response(request)
end
if rule
rule.response(request)
else
OpenProject::RateLimiting::Base.response(request)
end
def blocklisted_response(request)
matched = request.env["rack.attack.matched"]
rule = find_rule(matched)
rule ? rule.blocked_response : [403, {}, ["Forbidden\n"]]
end
def find_rule(matched)
active_rules.find { |r| matched == r.rule_name || matched.start_with?("#{r.rule_name}/") }
end
end
end
+11 -1
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module OpenProject
module RateLimiting
class Base
@@ -54,6 +56,14 @@ module OpenProject
"Your request has been throttled. Try again at #{retry_after.seconds.from_now}.\n"
end
def blocked_response
[429, { "Content-Type" => "text/plain" }, [blocked_response_body]]
end
def blocked_response_body
"Your request has been blocked.\n"
end
protected
# Provide a limit callback proc for the request, or use the default limit
@@ -91,7 +101,7 @@ module OpenProject
false
end
def discriminator(request)
def discriminator(_request)
raise SubclassResponsibilityError
end
+64
View File
@@ -0,0 +1,64 @@
# frozen_string_literal: true
module OpenProject
module RateLimiting
# Per-account HTTP-layer brute-force protection for POST /login.
#
# Uses Rack::Attack::Allow2Ban: the first BURST_LIMIT attempts within
# BURST_PERIOD are allowed through; once the limit is exceeded a ban flag
# is written that blocks all subsequent attempts for BAN_PERIOD.
#
# Enabled by default. Disable or tune via configuration.yml:
#
# rate_limiting:
# login:
# enabled: false
class Login < Base
BURST_LIMIT = 20
BURST_PERIOD = 1.minute.to_i
BAN_PERIOD = 30.minutes.to_i
class << self
def enabled_by_default?
true
end
end
def apply!
Rack::Attack.blocklist(rule_name) do |req|
next false unless req.post? && req.path == "/login"
username = req.env.dig("rack.request.form_hash", "username").to_s.downcase.presence
next false unless username
Rack::Attack::Allow2Ban.filter(
"login:#{username}",
maxretry: burst_limit,
findtime: burst_period,
bantime: ban_period
) { true }
end
self
end
def blocked_response_body
"Too many login attempts. Please try again later.\n"
end
protected
def burst_limit
settings[:burst_limit].presence&.to_i || BURST_LIMIT
end
def burst_period
settings[:burst_period].presence&.to_i || BURST_PERIOD
end
def ban_period
settings[:ban_period].presence&.to_i || BAN_PERIOD
end
end
end
end