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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user