From beabc1fe5b11fdcd45f756a10180d99c9c09402d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 8 Jun 2026 09:36:53 +0200 Subject: [PATCH] webui: rate-limit POST /login via nginx limit_req Adds an nginx-side rate limit on POST /login so a brute-force or DoS attempt can't pin the box on the bcrypt-shaped credential check inside rousette/PAM. Configuration: - Keyed on $binary_remote_addr (per source IP), 5 requests/minute sustained with a burst of 3 nodelay, a real user fumbling their password three times in a row sails through; the 4th attempt in the same window returns 429. - GET /login is unmetered (the keyed map yields the empty string, which limit_req treats as "no key, no limit"), so a 401-driven HX-Redirect bouncing the user back to the login page doesn't eat into the budget. - 32k zone (~500 IPs, the minimum nginx accepts). At cache saturation an attacker rotating addresses still tops out around ~2500 attempts/min, all serialised through bcrypt one at a time. - 429 instead of the default 503 keeps the response out of the /50x.html error_page rewrite (which auto-refreshes and would loop a throttled client straight back to /login). Signed-off-by: Joachim Wiberg --- package/webui/default.conf | 20 ++++++++++++++++++++ package/webui/webui.conf | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/package/webui/default.conf b/package/webui/default.conf index 1e66a2e7..20707bf6 100644 --- a/package/webui/default.conf +++ b/package/webui/default.conf @@ -1,3 +1,23 @@ +# Throttle POST /login so a brute-force attempt can't pin the box on +# the bcrypt-shaped credential check inside rousette/PAM. GET is left +# unmetered: page loads after a 401-driven HX-Redirect shouldn't eat +# into the budget. +# +# Zone is 32k (~500 IPs) — the minimum nginx accepts via shared-memory +# allocation. With the cache full an attacker rotating source addresses +# still tops out at ~2500 attempts/min total, all serialised through +# bcrypt one at a time. +# +# 429 instead of the default 503 keeps the rate-limit response out of +# the /50x.html error_page rewrite below (which auto-refreshes — would +# loop a throttled client straight back to /login). +map $request_method $webui_login_key { + POST $binary_remote_addr; + default ""; +} +limit_req_zone $webui_login_key zone=webui_login:32k rate=5r/m; +limit_req_status 429; + server { listen 80; listen [::]:80; diff --git a/package/webui/webui.conf b/package/webui/webui.conf index 0709fdc4..17111bc2 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -8,6 +8,13 @@ location / { include /etc/nginx/webui-proxy.conf; } +# burst=3 nodelay: three POSTs land back-to-back; the next needs a +# fresh token (~12 s at 5r/m) or 429s. +location = /login { + limit_req zone=webui_login burst=3 nodelay; + include /etc/nginx/webui-proxy.conf; +} + # Keep proxy_request_buffering on (the default). Streaming the body races # the Go handler's response close, producing RST instead of FIN at # Content-Length and a client-visible 502. nginx spools the body to