diff --git a/lib/volumen.rb b/lib/volumen.rb index 1265e41..51701a6 100644 --- a/lib/volumen.rb +++ b/lib/volumen.rb @@ -18,4 +18,11 @@ require_relative "volumen/users" # The Sinatra app (`Volumen::Server`) and the CLI (`Volumen::CLI`) are loaded # on demand to keep the core library light. module Volumen + def self.login_attempts + @login_attempts ||= {} + end + + def self.reset_login_attempts! + @login_attempts = {} + end end diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index 31c0968..9b4b1bd 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -13,6 +13,9 @@ module Volumen # Sinatra application: the public JSON API at /api/volumen and the # server-rendered admin at /admin. class Server < Sinatra::Base + MAX_LOGIN_ATTEMPTS = 10 + LOGIN_WINDOW = 60 # seconds + configure do set :show_exceptions, false set :raise_errors, false @@ -102,6 +105,7 @@ module Volumen post "/admin/login" do check_csrf! + check_login_rate_limit! user = users.authenticate(params["username"], params["password"]) if user session[:user] = user.username @@ -387,6 +391,19 @@ module Volumen halt(403, "Invalid CSRF token") 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) @error = error @notice = notice diff --git a/test/admin_test.rb b/test/admin_test.rb index 51ea93a..1026797 100644 --- a/test/admin_test.rb +++ b/test/admin_test.rb @@ -30,6 +30,7 @@ class AdminTest < Minitest::Test TOML @config = Volumen::Config.load(path: config_path) @store = Volumen::Store.new(@posts_dir, default_lang: "en") + Volumen.reset_login_attempts! end def teardown