From b3f2eafe45a6daf556ee56d99ff3e56ddea55e6f Mon Sep 17 00:00:00 2001 From: Christian Chwala Date: Wed, 29 Jul 2026 23:27:14 +0200 Subject: [PATCH] feat(webserver): add rate limiting to prevent brute-force and DoS attacks - Add Flask-Limiter dependency for rate limiting - Limit login endpoint to 5 requests/minute (prevents credential stuffing) - Limit file upload to 10 requests/minute (prevents upload flooding) - Limit file listing to 30 requests/minute (prevents enumeration) - Add 429 error handler with JSON response - Support memory storage by default, configurable via RATE_LIMIT_STORAGE_URI env var Security impact: Mitigates brute-force attacks on login and resource exhaustion via API endpoints. --- docker-compose.yml | 1 + webserver/main.py | 39 ++++++++++++++++++++++++++++++++++++++ webserver/requirements.txt | 1 + 3 files changed, 41 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index f313714..4f29c30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -163,6 +163,7 @@ services: - USERS_CONFIG_PATH=/app/configs/users.json - STORAGE_BACKEND=local # Options: local, s3, minio - STORAGE_BASE_PATH=/app/data + - RATE_LIMIT_STORAGE_URI=memory:// volumes: - webserver_data_staged:/app/data/staged - webserver_data_archived:/app/data/archived diff --git a/webserver/main.py b/webserver/main.py index aede7ab..9e5fa75 100644 --- a/webserver/main.py +++ b/webserver/main.py @@ -26,6 +26,8 @@ login_required, current_user, ) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from werkzeug.security import check_password_hash from werkzeug.utils import secure_filename from datetime import datetime, timedelta, timezone @@ -50,6 +52,17 @@ login_manager.login_view = "login" login_manager.login_message = "Please log in to access this page." +# ── Rate Limiting ─────────────────────────────────────────────────────────── +limiter_storage_uri = os.getenv("RATE_LIMIT_STORAGE_URI", "memory://") +limiter = Limiter( + key_func=get_remote_address, + app=app, + default_limits=[], + storage_uri=limiter_storage_uri, + strategy="fixed-window", +) + + class User(UserMixin): def __init__(self, user_id: str): @@ -142,6 +155,10 @@ def user_db_scope(user_id: str): @app.route("/login", methods=["GET", "POST"]) +@limiter.limit( + "5 per minute", + error_message="Too many login attempts. Please try again later.", +) def login(): if current_user.is_authenticated: return redirect(url_for("overview")) @@ -882,6 +899,10 @@ def get_file_size_mb(filepath): @app.route("/api/upload", methods=["POST"]) +@limiter.limit( + "10 per minute", + error_message="Too many upload attempts. Please slow down.", +) @login_required def upload_file(): """Handle file upload via drag and drop""" @@ -946,6 +967,10 @@ def upload_file(): @app.route("/api/files", methods=["GET"]) +@limiter.limit( + "30 per minute", + error_message="Too many requests. Please slow down.", +) @login_required def get_files(): """Get list of files in data_incoming and data_staged_for_parsing directories""" @@ -1004,6 +1029,20 @@ def get_files(): # ==================== ERROR HANDLERS ==================== +@app.errorhandler(429) +def ratelimit_handler(e): + """Handle rate limit exceeded errors.""" + return ( + jsonify( + { + "error": "Rate limit exceeded", + "message": str(e.description), + } + ), + 429, + ) + + @app.errorhandler(404) def not_found(error): return render_template("404.html"), 404 diff --git a/webserver/requirements.txt b/webserver/requirements.txt index e9a5a70..f2394dc 100644 --- a/webserver/requirements.txt +++ b/webserver/requirements.txt @@ -1,5 +1,6 @@ Flask==2.3.3 Flask-Login==0.6.3 +Flask-Limiter==3.5.0 psycopg2-binary==2.9.7 folium==0.14.0 gunicorn==22.0.0