Skip to content

Commit bf2dfeb

Browse files
Harden login rate limiting
Signed-off-by: Yoshifumi Nakamura <nakamura@riken.jp>
1 parent e08fd4b commit bf2dfeb

10 files changed

Lines changed: 292 additions & 106 deletions

File tree

docs/cx/AUDIT_LOG_SPEC.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ Common fields:
3939
| `api_auth_failed` | `failure` | none | Missing or invalid API key. The presented key is never logged. |
4040
| `ingest_accepted` | `success` | runner id | Result, estimate, PA data, or estimation-input upload accepted. |
4141
| `api_query_accepted` | `success` | runner id | Authenticated API query accepted. |
42-
| `rate_limit_exceeded` | `failure` | rate-limit key | API or admin fixed-window limit exceeded. |
42+
| `rate_limit_exceeded` | `failure` | rate-limit key | Login, API, or admin fixed-window limit exceeded. |
4343
| `redis_unavailable` | `failure` / `degraded` | none | Redis unavailable for authentication or throttling. |
4444
| `login_success` | `success` | user email | User completed TOTP login. |
45-
| `login_failure` | `failure` | user email, when known | Login failed. TOTP code is never logged. |
45+
| `login_failure` | `failure` | user email, when known | Login failed. TOTP code is never logged. Invalid-code failures include a short-window failed-attempt count for audit context, but accounts are not hard-locked by that counter. |
4646
| `setup_complete` | `success` | user email | Invitation-based TOTP setup completed. |
4747
| `setup_failure` | `failure` | user email | TOTP setup verification failed. |
4848
| `admin_user_invited` | `success` | admin email | Admin created an invitation. |

docs/deploy/hardening-guide.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,38 @@ oversized uploads are rejected before they consume worker memory.
2222

2323
## Rate Limits
2424

25-
API ingest/query routes and admin write routes use Redis-backed fixed-window
26-
rate limits. Production deployments must keep Redis monitored and available;
27-
when Redis is required but unavailable, protected operations fail closed with a
28-
503 response.
25+
Login POST, API ingest/query routes, and admin write routes use Redis-backed
26+
fixed-window rate limits. Production deployments must keep Redis monitored and
27+
available; when Redis is required but unavailable, protected operations fail
28+
closed with a 503 response.
2929

3030
Default limits:
3131

32+
- Login verification: 20 requests per client source per minute
3233
- API ingest: 120 requests per runner per minute
3334
- API query: 60 requests per runner per minute
3435
- Admin write actions: 20 requests per admin user per minute
3536

37+
Login failures also maintain a short-lived per-email counter for audit and
38+
alerting context, but the counter does not hard-lock the account. This avoids a
39+
targeted lockout DoS where an attacker can repeatedly close a known user's
40+
login window; volume control is enforced by the source-scoped login limit.
41+
3642
## Reverse Proxy
3743

3844
Run the Flask app behind a reverse proxy that terminates TLS and forwards only
3945
loopback traffic to the app. Keep `/admin/` and `/auth/` protected by portal
4046
authentication; `robots.txt` only reduces crawler noise and is not an access
4147
control mechanism.
4248

49+
`app.py` trusts one reverse proxy hop with Werkzeug `ProxyFix`, so the frontend
50+
proxy must set `X-Forwarded-For` and `X-Forwarded-Proto`. The configured hop
51+
count assumes nginx is the only trusted proxy directly in front of Gunicorn. If
52+
a load balancer or another proxy is inserted before nginx, review the
53+
`ProxyFix` hop count and nginx header handling before enabling the deployment.
54+
Do not expose Gunicorn directly to untrusted clients, because forwarded headers
55+
are trusted only under the single-nginx deployment model.
56+
4357
## Gunicorn
4458

4559
Run Gunicorn under systemd with explicit worker, bind, timeout, and recycling

docs/guides/developer-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ For production portal deployments:
220220
- The legacy `RESULT_SERVER_KEY` variable is still accepted as runner `default` for compatibility, but should be rotated to `RESULT_SERVER_KEYS`.
221221
- See `docs/deploy/key-management.md` for generation and rotation guidance.
222222
- `REDIS_URL` must point to a monitored Redis instance; production authentication refuses login when Redis is unavailable.
223-
- API ingest and query endpoints use Redis-backed rate limits by default; set `RESULT_SERVER_MAX_UPLOAD_MB` and `RESULT_SERVER_MAX_ARCHIVE_MEMBER_MB` when deployment-specific upload limits are needed.
223+
- Login verification, API ingest/query, and admin write endpoints use Redis-backed rate limits by default; set `RESULT_SERVER_MAX_UPLOAD_MB` and `RESULT_SERVER_MAX_ARCHIVE_MEMBER_MB` when deployment-specific upload limits are needed.
224+
- Repeated login failures are tracked per email for audit context only; source-scoped Redis rate limits enforce login traffic control without hard-locking a target account.
224225
- Admin-managed affiliations are only rejected when they contain unsafe path/control characters or the comma delimiter used by the form; set `RESULT_SERVER_ALLOWED_AFFILIATIONS` only when a deployment wants to enforce a fixed comma-separated allowlist.
225226
- Security-relevant auth, API, and admin actions emit structured `benchkit.audit` events; see `docs/cx/AUDIT_LOG_SPEC.md`.
226227
- `app_dev.py` is localhost-only, uses ephemeral development secrets when none are provided, and enables the Werkzeug debugger only with `RESULT_SERVER_DEV_DEBUG=1`.

result_server/app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from flask import Flask, jsonify, render_template
66
from flask_session import Session
7+
from werkzeug.middleware.proxy_fix import ProxyFix
78

89
from routes.api import api_bp
910
from routes.estimated import estimated_bp
@@ -42,6 +43,11 @@ def _configure_session(app, base_dir):
4243
Session(app)
4344

4445

46+
def _configure_proxy_fix(app):
47+
"""Trust the single nginx reverse proxy in front of production gunicorn."""
48+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
49+
50+
4551
def _configure_redis(app, prefix):
4652
"""Attach Redis connection settings and the key prefix to app config."""
4753
import redis
@@ -123,6 +129,7 @@ def create_app(prefix="", base_dir=None):
123129
raise ValueError("base_dir must be specified")
124130

125131
app = Flask(__name__, template_folder="templates")
132+
_configure_proxy_fix(app)
126133

127134
secret_key = os.environ.get("FLASK_SECRET_KEY")
128135
if not secret_key:

result_server/app_dev.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def _create_stub_totp_manager():
8484
mod.generate_qr_base64 = lambda s, e, **kw: ""
8585
mod.verify_code = lambda s, c: True
8686
mod.check_code_reuse = lambda *a, **kw: False
87-
mod.check_rate_limit = lambda *a, **kw: False
87+
mod.get_failed_attempt_count = lambda *a, **kw: 0
8888
mod.record_failed_attempt = lambda *a, **kw: 0
8989
mod.clear_failed_attempts = lambda *a, **kw: None
9090
mod.MAX_LOGIN_ATTEMPTS = 5

result_server/routes/auth.py

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414
session,
1515
url_for,
1616
)
17+
from werkzeug.exceptions import TooManyRequests
1718

1819
from utils.audit_logging import audit_event
20+
from utils.rate_limit import enforce_rate_limit
1921
from utils.totp_manager import (
2022
check_code_reuse,
21-
check_rate_limit,
2223
clear_failed_attempts,
2324
generate_qr_base64,
2425
generate_secret,
26+
MAX_LOGIN_ATTEMPTS,
2527
record_failed_attempt,
2628
verify_code,
2729
)
@@ -115,6 +117,11 @@ def _render_setup_page(email, token, secret):
115117
)
116118

117119

120+
def _login_rate_key():
121+
"""Return the source-scoped login rate-limit key."""
122+
return request.remote_addr or "unknown"
123+
124+
118125
@auth_bp.route("/login", methods=["GET", "POST"])
119126
def login():
120127
"""Render the login flow and validate submitted TOTP codes."""
@@ -138,19 +145,17 @@ def login():
138145
if not email:
139146
return redirect(url_for("auth.login"))
140147

141-
# Enforce rate limiting when Redis-backed tracking is available.
142148
if redis_conn:
143-
is_locked, remaining = check_rate_limit(redis_conn, prefix, email)
144-
if is_locked:
145-
audit_event(
146-
"login_failure",
147-
actor=email,
148-
result="failure",
149-
level=logging.WARNING,
150-
details={"reason": "rate_limited"},
149+
try:
150+
enforce_rate_limit(
151+
redis_conn=redis_conn,
152+
key_suffix=_login_rate_key(),
153+
max_per_minute=20,
154+
scope="login",
151155
)
152-
flash(f"Too many failed attempts. Please try again in {remaining} seconds.")
153-
return _render_login_totp_step(email)
156+
except TooManyRequests:
157+
flash("Too many login attempts. Please wait and try again.")
158+
return _render_login_totp_step(email), 429
154159

155160
store = get_user_store()
156161
user = store.get_user(email)
@@ -179,26 +184,25 @@ def login():
179184
flash("Authentication successful.")
180185
return redirect(url_for("results.results"))
181186

182-
# Failed authentication: record or report the attempt.
187+
# Failed authentication: record a short-window counter for audit/alerting.
188+
details = {"reason": "invalid_totp_or_user"}
189+
if redis_conn:
190+
attempts = record_failed_attempt(redis_conn, prefix, email)
191+
details.update(
192+
{
193+
"failed_attempts": attempts,
194+
"failed_attempt_threshold": MAX_LOGIN_ATTEMPTS,
195+
"threshold_exceeded": attempts >= MAX_LOGIN_ATTEMPTS,
196+
}
197+
)
183198
audit_event(
184199
"login_failure",
185200
actor=email,
186201
result="failure",
187202
level=logging.WARNING,
188-
details={"reason": "invalid_totp_or_user"},
203+
details=details,
189204
)
190-
if redis_conn:
191-
attempts = record_failed_attempt(redis_conn, prefix, email)
192-
from utils.totp_manager import MAX_LOGIN_ATTEMPTS
193-
194-
remaining_attempts = MAX_LOGIN_ATTEMPTS - attempts
195-
if remaining_attempts > 0:
196-
flash(f"Authentication failed. {remaining_attempts} attempts remaining.")
197-
else:
198-
flash("Too many failed attempts. Your account is temporarily locked.")
199-
return _render_login_totp_step(email)
200-
else:
201-
flash("Authentication failed. Please check your code.")
205+
flash("Authentication failed. Please check your code.")
202206
return _render_login_totp_step(email)
203207

204208
return redirect(url_for("auth.login"))

result_server/tests/test_rate_limit.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ def test_rate_limit_redis_failure_fails_closed_when_required():
178178
_cleanup(temp_dirs)
179179

180180

181+
def test_rate_limit_missing_redis_fails_closed_when_required():
182+
app, temp_dirs = _api_app()
183+
app.config["REDIS_CONN"] = None
184+
app.config["AUTH_REQUIRES_REDIS"] = True
185+
try:
186+
with app.test_client() as client:
187+
resp = client.post(
188+
"/api/ingest/result",
189+
data=json.dumps({"code": "first"}),
190+
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
191+
)
192+
193+
assert resp.status_code == 503
194+
finally:
195+
_cleanup(temp_dirs)
196+
197+
181198
def test_admin_write_rate_limit_returns_429():
182199
app, temp_dirs = _portal_app()
183200
try:

0 commit comments

Comments
 (0)