feat: add in-memory rate limiting for login endpoint

Rate-limit POST /admin/login at 10 attempts per 60 seconds per IP.
Stored in Volumen.login_attempts so tests can reset the counter.
This commit is contained in:
2026-06-25 21:33:16 +02:00
parent 2348352fab
commit 140b16b55a
3 changed files with 25 additions and 0 deletions
+7
View File
@@ -18,4 +18,11 @@ require_relative "volumen/users"
# The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded # The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded
# on demand to keep the core library light. # on demand to keep the core library light.
module Volumen module Volumen
def self.login_attempts
@login_attempts ||= {}
end
def self.reset_login_attempts!
@login_attempts = {}
end
end end
+17
View File
@@ -13,6 +13,9 @@ module Volumen
# Sinatra application: the public JSON API at /api/volumen and the # Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin. # server-rendered admin at /admin.
class Server < Sinatra::Base class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds
configure do configure do
set :show_exceptions, false set :show_exceptions, false
set :raise_errors, false set :raise_errors, false
@@ -102,6 +105,7 @@ module Volumen
post "/admin/login" do post "/admin/login" do
check_csrf! check_csrf!
check_login_rate_limit!
user = users.authenticate(params["username"], params["password"]) user = users.authenticate(params["username"], params["password"])
if user if user
session[:user] = user.username session[:user] = user.username
@@ -387,6 +391,19 @@ module Volumen
halt(403, "Invalid CSRF token") halt(403, "Invalid CSRF token")
end end
def check_login_rate_limit!
ip = request.ip.to_s
now = Time.now.to_f
cutoff = now - LOGIN_WINDOW
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
if attempts.length >= MAX_LOGIN_ATTEMPTS
halt(429, "Too many login attempts. Please wait and try again.")
end
attempts << now
Volumen.login_attempts[ip] = attempts
end
def render_settings(error: nil, notice: nil) def render_settings(error: nil, notice: nil)
@error = error @error = error
@notice = notice @notice = notice
+1
View File
@@ -30,6 +30,7 @@ class AdminTest < Minitest::Test
TOML TOML
@config = Volumen::Config.load(path: config_path) @config = Volumen::Config.load(path: config_path)
@store = Volumen::Store.new(@posts_dir, default_lang: "en") @store = Volumen::Store.new(@posts_dir, default_lang: "en")
Volumen.reset_login_attempts!
end end
def teardown def teardown