From: Tom Hughes <tom@compton.nu>
Date: Tue, 22 Aug 2023 17:32:53 +0000 (+0100)
Subject: Add support for rate limiting signup requests
X-Git-Tag: live~1797^2
X-Git-Url: https://git.openstreetmap.org/rails.git/commitdiff_plain/63bf18a3c3de2a5cac0087658ec2a0cbd275ed1c

Add support for rate limiting signup requests
---

diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 0f9e16767..9d4b3d258 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -186,6 +186,9 @@ class UsersController < ApplicationController
         end
 
         if current_user.save
+          SIGNUP_IP_LIMITER&.update(request.remote_ip)
+          SIGNUP_EMAIL_LIMITER&.update(canonical_email(current_user.email))
+
           flash[:matomo_goal] = Settings.matomo["goals"]["signup"] if defined?(Settings.matomo)
 
           referer = welcome_path
@@ -344,7 +347,13 @@ class UsersController < ApplicationController
                    domain_mx_servers(domain)
                  end
 
-    if blocked = Acl.no_account_creation(request.remote_ip, :domain => domain, :mx => mx_servers)
+    blocked = Acl.no_account_creation(request.remote_ip, :domain => domain, :mx => mx_servers)
+
+    blocked ||= SIGNUP_IP_LIMITER && !SIGNUP_IP_LIMITER.allow?(request.remote_ip)
+
+    blocked ||= email && SIGNUP_EMAIL_LIMITER && !SIGNUP_EMAIL_LIMITER.allow?(canonical_email(email))
+
+    if blocked
       logger.info "Blocked signup from #{request.remote_ip} for #{email}"
 
       render :action => "blocked"
@@ -353,6 +362,20 @@ class UsersController < ApplicationController
     !blocked
   end
 
+  def canonical_email(email)
+    local_part, domain = if email.nil?
+                           nil
+                         else
+                           email.split("@")
+                         end
+
+    local_part.sub!(/\+.*$/, "")
+
+    local_part.delete!(".") if %w[gmail.com googlemail.com].include?(domain)
+
+    "#{local_part}@#{domain}"
+  end
+
   ##
   # get list of MX servers for a domains
   def domain_mx_servers(domain)
diff --git a/config/initializers/rate_limits.rb b/config/initializers/rate_limits.rb
new file mode 100644
index 000000000..5caa3007f
--- /dev/null
+++ b/config/initializers/rate_limits.rb
@@ -0,0 +1,15 @@
+require "rate_limiter"
+
+SIGNUP_IP_LIMITER = if Settings.memcache_servers && Settings.signup_ip_per_day && Settings.signup_ip_max_burst
+                      RateLimiter.new(
+                        Dalli::Client.new(Settings.memcache_servers, :namespace => "rails:signup:ip"),
+                        86400, Settings.signup_ip_per_day, Settings.signup_ip_max_burst
+                      )
+                    end
+
+SIGNUP_EMAIL_LIMITER = if Settings.memcache_servers && Settings.signup_email_per_day && Settings.signup_email_max_burst
+                         RateLimiter.new(
+                           Dalli::Client.new(Settings.memcache_servers, :namespace => "rails:signup:email"),
+                           86400, Settings.signup_email_per_day, Settings.signup_email_max_burst
+                         )
+                       end
diff --git a/config/settings.yml b/config/settings.yml
index d9910ce28..8ac27df40 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -140,3 +140,8 @@ smtp_user_name: null
 smtp_password: null
 # Matomo settings for analytics
 #matomo:
+# Signup rate limits
+#signup_ip_per_day:
+#signup_ip_max_burst:
+#signup_email_per_day:
+#signup_email_max_burst:
diff --git a/lib/rate_limiter.rb b/lib/rate_limiter.rb
new file mode 100644
index 000000000..438f5a1e2
--- /dev/null
+++ b/lib/rate_limiter.rb
@@ -0,0 +1,38 @@
+class RateLimiter
+  def initialize(cache, interval, limit, max_burst)
+    @cache = cache
+    @requests_per_second = limit.to_f / interval
+    @burst_limit = max_burst
+  end
+
+  def allow?(key)
+    last_update, requests = @cache.get(key)
+
+    if last_update
+      elapsed = Time.now.to_i - last_update
+
+      requests -= elapsed * @requests_per_second
+    else
+      requests = 0.0
+    end
+
+    requests < @burst_limit
+  end
+
+  def update(key)
+    now = Time.now.to_i
+
+    last_update, requests = @cache.get(key)
+
+    if last_update
+      elapsed = now - last_update
+
+      requests -= elapsed * @requests_per_second
+      requests += 1.0
+    else
+      requests = 1.0
+    end
+
+    @cache.set(key, [now, [requests, 1.0].max])
+  end
+end