diff --git a/otterwiki/server.py b/otterwiki/server.py index 744d2087..76c97af3 100644 --- a/otterwiki/server.py +++ b/otterwiki/server.py @@ -6,7 +6,7 @@ import sys import logging -from flask import Flask +from flask import Flask, request from flask_mail import Mail from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect @@ -78,6 +78,10 @@ SECURITY_HEADERS=True, WTF_CSRF_ENABLED=True, WTF_CSRF_TIME_LIMIT=86400, + PROXY_FIX_X_FOR=0, + RATELIMIT_ENABLED=True, + RATELIMIT_LOGIN="10/minute", + RATELIMIT_LOST_PASSWORD="5/minute", ) app.config.from_envvar("OTTERWIKI_SETTINGS", silent=True) @@ -100,6 +104,14 @@ else: app.config[key] = os.environ[key] +# ProxyFix for reverse proxy deployments +proxy_fix_x_for = int(app.config.get("PROXY_FIX_X_FOR", 0)) +if proxy_fix_x_for > 0: + from werkzeug.middleware.proxy_fix import ProxyFix + + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=proxy_fix_x_for) + app.logger.info(f"server: ProxyFix enabled with x_for={proxy_fix_x_for}") + # configure logging app.logger.setLevel(app.config["LOG_LEVEL"]) logging.getLogger('werkzeug').setLevel(app.config["LOG_LEVEL_WERKZEUG"]) @@ -222,6 +234,33 @@ def update_app_config(): db.create_all() update_app_config() +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +limiter = Limiter( + get_remote_address, + app=app, + storage_uri="memory://", + enabled=app.config.get("RATELIMIT_ENABLED", True), +) + + +@app.errorhandler(429) +def ratelimit_handler(e): + from flask import render_template + from otterwiki.helper import toast + + app.logger.warning( + f"Rate limit exceeded: {request.remote_addr} on {request.path}" + ) + toast("Too many attempts. Please try again later.", "error") + if "lost_password" in request.path: + return ( + render_template("lost_password.html", title="Lost password"), + 429, + ) + return render_template("login.html", title="Login"), 429 + # # a renderer configured with the app.config diff --git a/otterwiki/views.py b/otterwiki/views.py index 7d6cb957..c4a4b764 100644 --- a/otterwiki/views.py +++ b/otterwiki/views.py @@ -16,7 +16,7 @@ jsonify, g, ) -from otterwiki.server import app, githttpserver +from otterwiki.server import app, githttpserver, limiter from otterwiki.wiki import ( Page, Changelog, @@ -357,7 +357,28 @@ def create(): # # user login/logout/settings # +def _login_rate_limit_key(): + """Rate limit key combining IP and email for login brute-force protection.""" + email = request.form.get("email", "").lower().strip() + if email: + return f"{request.remote_addr}:{email}" + return request.remote_addr + + +def _lost_password_rate_limit_key(): + """Rate limit key combining IP and email for lost password protection.""" + email = request.form.get("email", "").lower().strip() + if email: + return f"{request.remote_addr}:{email}" + return request.remote_addr + + @app.route("/-/login", methods=["POST", "GET"]) +@limiter.limit( + lambda: app.config.get("RATELIMIT_LOGIN", "10/minute"), + key_func=_login_rate_limit_key, + methods=["POST"], +) def login(): email = request.cookies.get("email") if request.method == "GET": @@ -390,6 +411,11 @@ def logout(): @app.route("/-/lost_password", methods=["POST", "GET"]) +@limiter.limit( + lambda: app.config.get("RATELIMIT_LOST_PASSWORD", "5/minute"), + key_func=_lost_password_rate_limit_key, + methods=["POST"], +) def lost_password(): if request.method == "GET": return otterwiki.auth.lost_password_form() diff --git a/pyproject.toml b/pyproject.toml index e6c49875..ffbebc97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "pluggy==1.5.0", "regex==2026.2.28", "feedgen==1.0.0", + "Flask-Limiter==4.1.1", "flask-wtf==1.2.2", ] keywords = ["wiki", "git", "markdown"] diff --git a/tests/conftest.py b/tests/conftest.py index 889b574b..7f32ea18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,7 @@ def create_app(tmpdir): "SITE_NAME = 'TEST WIKI'\n", "DEBUG = True\n", # enable test and debug settings "TESTING = True\n", + "RATELIMIT_ENABLED = False\n", "MAIL_SUPPRESS_SEND = True\n", "SECRET_KEY = 'Testing Testing Testing'\n", ] diff --git a/tests/test_auth.py b/tests/test_auth.py index e4c19233..dba94fde 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1167,3 +1167,85 @@ def test_login_next_full_flow(app_with_user): # step 4: follow redirect to the original page rv = test_client.get(rv.headers["Location"]) assert rv.status_code == 200 + + +# +# rate limiting tests +# +@pytest.fixture +def ratelimit_client(app_with_user): + """Test client with rate limiting enabled for testing rate limit behavior.""" + from otterwiki.server import limiter + + app_with_user.config["RATELIMIT_ENABLED"] = True + old_got_first_request = app_with_user._got_first_request + app_with_user._got_first_request = False + limiter.init_app(app_with_user) + app_with_user._got_first_request = old_got_first_request + limiter.reset() + client = app_with_user.test_client() + yield client + limiter.reset() + limiter.enabled = False + app_with_user.config["RATELIMIT_ENABLED"] = False + app_with_user.config["RATELIMIT_ENABLED"] = False + + +def test_login_ratelimit(ratelimit_client): + """POST /-/login is rate limited to 10/minute; 11th request returns 429 with error message.""" + for i in range(10): + result = ratelimit_client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert result.status_code != 429, f"Request {i + 1} was rate limited" + result = ratelimit_client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert result.status_code == 429 + assert b"Too many attempts" in result.data + + +def test_lost_password_ratelimit(ratelimit_client): + """POST /-/lost_password is rate limited to 5/minute; 6th request returns 429 with error message.""" + for i in range(5): + result = ratelimit_client.post( + "/-/lost_password", + data={"email": "mail@example.org"}, + follow_redirects=False, + ) + assert result.status_code != 429, f"Request {i + 1} was rate limited" + result = ratelimit_client.post( + "/-/lost_password", + data={"email": "mail@example.org"}, + follow_redirects=False, + ) + assert result.status_code == 429 + assert b"Too many attempts" in result.data + + +def test_login_get_not_ratelimited(ratelimit_client): + """GET /-/login is never rate limited (only POST is).""" + for i in range(50): + result = ratelimit_client.get("/-/login") + assert result.status_code in ( + 200, + 302, + ), f"GET request {i + 1} returned unexpected status {result.status_code}" + + +def test_ratelimit_disabled_in_tests(app_with_user): + """With RATELIMIT_ENABLED=False (test default), rate limits are not enforced.""" + client = app_with_user.test_client() + for i in range(20): + result = client.post( + "/-/login", + data={"email": "mail@example.org", "password": "wrongpassword"}, + follow_redirects=False, + ) + assert ( + result.status_code != 429 + ), f"Request {i + 1} was rate limited despite RATELIMIT_ENABLED=False"